From ee90470455fe89aa9da124b5670ece657cd1258d Mon Sep 17 00:00:00 2001 From: SHASHANK SHEKHAR Date: Fri, 25 Sep 2026 12:36:11 +0530 Subject: [PATCH 1/3] perf: add scalability benchmarks --- apps/api/scripts/benchmark-event-pipeline.ts | 171 ++++++++++++++++++ .../scripts/create-performance-identity.ts | 92 ++++++++++ performance/k6/README.md | 48 +++++ performance/k6/auth-login.js | 49 +++++ performance/k6/auth-logout.js | 114 ++++++++++++ performance/k6/auth-refresh.js | 116 ++++++++++++ performance/k6/health-horizontal.js | 56 ++++++ performance/k6/health-scalability.js | 37 ++++ performance/k6/health.js | 30 +++ performance/k6/rpc-bitcoin.js | 44 +++++ performance/pgbench/read-only.sql | 7 + 11 files changed, 764 insertions(+) create mode 100644 apps/api/scripts/benchmark-event-pipeline.ts create mode 100644 apps/api/scripts/create-performance-identity.ts create mode 100644 performance/k6/README.md create mode 100644 performance/k6/auth-login.js create mode 100644 performance/k6/auth-logout.js create mode 100644 performance/k6/auth-refresh.js create mode 100644 performance/k6/health-horizontal.js create mode 100644 performance/k6/health-scalability.js create mode 100644 performance/k6/health.js create mode 100644 performance/k6/rpc-bitcoin.js create mode 100644 performance/pgbench/read-only.sql diff --git a/apps/api/scripts/benchmark-event-pipeline.ts b/apps/api/scripts/benchmark-event-pipeline.ts new file mode 100644 index 0000000..4ac5ffc --- /dev/null +++ b/apps/api/scripts/benchmark-event-pipeline.ts @@ -0,0 +1,171 @@ +import { randomUUID } from "node:crypto"; + +import { Kafka } from "kafkajs"; +import { PostgresStorage } from "@crypto-wallet/storage"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required"); +} + +const broker = process.env.KAFKA_BROKERS ?? "127.0.0.1:19092"; +const topic = process.env.KAFKA_TOPIC ?? "crypto-wallet.events"; +const consumerName = "exchange-deposit-temporal"; + +const eventCount = Number(process.env.EVENT_COUNT ?? "20"); +const timeoutMs = Number(process.env.EVENT_TIMEOUT_MS ?? "30000"); +const pollIntervalMs = 100; + +if (!Number.isInteger(eventCount) || eventCount <= 0) { + throw new Error("EVENT_COUNT must be a positive integer"); +} + +const kafka = new Kafka({ + clientId: `crypto-wallet-performance-${randomUUID()}`, + brokers: [broker], +}); + +const producer = kafka.producer(); + +const database = new PostgresStorage(databaseUrl); + +interface InboxRow { + event_id: string; + received_at: Date; + processed_at: Date | null; + attempts: number; + last_error: string | null; +} + +function createEvent() { + const depositId = randomUUID(); + + return { + eventId: randomUUID(), + eventType: "exchange.deposit.created", + eventVersion: 1, + occurredAt: new Date().toISOString(), + aggregateType: "exchange_deposit", + aggregateId: depositId, + correlationId: randomUUID(), + payload: { + depositId, + exchangeAccountId: randomUUID(), + assetAccountId: randomUUID(), + transactionHash: `0x${randomUUID().replaceAll("-", "")}`, + amount: "1.25000000", + }, + }; +} + +async function waitForProcessed(eventIds: string[]): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const result = await database.query( + ` + SELECT + event_id, + received_at, + processed_at, + attempts, + last_error + FROM consumer_inbox_events + WHERE consumer_name = $1 + AND event_id = ANY($2::uuid[]) + `, + [consumerName, eventIds], + ); + + const rows = result.rows; + + const failed = rows.find((row) => row.attempts > 0 && row.processed_at === null); + + if (failed) { + throw new Error( + `Consumer failure for event ${failed.event_id}: ${failed.last_error ?? "unknown error"}`, + ); + } + + if (rows.length === eventIds.length && rows.every((row) => row.processed_at !== null)) { + return rows; + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + throw new Error(`Timed out waiting for ${eventIds.length} events to be processed`); +} + +async function main(): Promise { + const events = Array.from({ length: eventCount }, createEvent); + const eventIds = events.map((event) => event.eventId); + + await database.connect(); + await producer.connect(); + + try { + const publishStartedAt = performance.now(); + + await producer.send({ + topic, + messages: events.map((event) => ({ + key: event.aggregateId, + value: JSON.stringify(event), + headers: { + eventType: event.eventType, + eventVersion: String(event.eventVersion), + correlationId: event.correlationId, + }, + })), + }); + + const publishFinishedAt = performance.now(); + + const rows = await waitForProcessed(eventIds); + + const completedAt = performance.now(); + + const processingLatencies = rows + .filter((row) => row.processed_at !== null) + .map((row) => row.processed_at!.getTime() - row.received_at.getTime()) + .sort((a, b) => a - b); + + const totalLatencyMs = completedAt - publishStartedAt; + const publishLatencyMs = publishFinishedAt - publishStartedAt; + + const percentile = (values: number[], percentileValue: number): number => { + if (values.length === 0) { + return 0; + } + + const index = Math.ceil((percentileValue / 100) * values.length) - 1; + + return values[Math.max(0, Math.min(index, values.length - 1))]; + }; + + console.log(""); + console.log("Event pipeline benchmark"); + console.log("------------------------"); + console.log(`Broker: ${broker}`); + console.log(`Topic: ${topic}`); + console.log(`Consumer: ${consumerName}`); + console.log(`Events published: ${events.length}`); + console.log(`Publish latency: ${publishLatencyMs.toFixed(2)} ms`); + console.log(`Total completion: ${totalLatencyMs.toFixed(2)} ms`); + console.log(`Consumer processing: p50=${percentile(processingLatencies, 50).toFixed(2)} ms`); + console.log(`Consumer processing: p95=${percentile(processingLatencies, 95).toFixed(2)} ms`); + console.log(`Consumer processing: p99=${percentile(processingLatencies, 99).toFixed(2)} ms`); + console.log( + `Processed successfully: ${rows.filter((row) => row.processed_at !== null).length}/${events.length}`, + ); + console.log(`Retries: ${rows.reduce((total, row) => total + row.attempts, 0)}`); + console.log(""); + } finally { + await producer.disconnect(); + await database.disconnect(); + } +} + +await main(); diff --git a/apps/api/scripts/create-performance-identity.ts b/apps/api/scripts/create-performance-identity.ts new file mode 100644 index 0000000..07408de --- /dev/null +++ b/apps/api/scripts/create-performance-identity.ts @@ -0,0 +1,92 @@ +import { randomUUID } from "node:crypto"; + +import { PostgresStorage } from "@crypto-wallet/storage"; + +import { hashPassword } from "../src/identity/password.js"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required"); +} + +const email = process.env.K6_AUTH_EMAIL ?? `k6-performance-${randomUUID()}@example.com`; + +const password = process.env.K6_AUTH_PASSWORD; + +if (!password) { + throw new Error("K6_AUTH_PASSWORD is required"); +} + +const userId = randomUUID(); +const deviceId = randomUUID(); +const identityAccountId = randomUUID(); +const passwordCredentialId = randomUUID(); + +const storage = new PostgresStorage(databaseUrl); + +try { + await storage.connect(); + + const passwordHash = await hashPassword(password); + + await storage.query( + ` + INSERT INTO users (id) + VALUES ($1) + `, + [userId], + ); + + await storage.query( + ` + INSERT INTO devices ( + id, + user_id, + platform, + name + ) + VALUES ($1, $2, $3, $4) + `, + [deviceId, userId, "test", "k6-performance"], + ); + + await storage.query( + ` + INSERT INTO identity_accounts ( + id, + user_id, + normalized_email, + status + ) + VALUES ($1, $2, $3, 'active') + `, + [identityAccountId, userId, email], + ); + + await storage.query( + ` + INSERT INTO password_credentials ( + id, + identity_account_id, + password_hash, + failed_attempt_count + ) + VALUES ($1, $2, $3, 0) + `, + [passwordCredentialId, identityAccountId, passwordHash], + ); + + console.log(""); + console.log("Performance identity created successfully."); + console.log(""); + console.log(`K6_AUTH_EMAIL=${email}`); + console.log(`K6_AUTH_PASSWORD=${password}`); + console.log(`K6_AUTH_DEVICE_ID=${deviceId}`); + console.log(""); + console.log(`USER_ID=${userId}`); + console.log(`DEVICE_ID=${deviceId}`); + console.log(""); +} finally { + await storage.disconnect(); +} diff --git a/performance/k6/README.md b/performance/k6/README.md new file mode 100644 index 0000000..bd1d46c --- /dev/null +++ b/performance/k6/README.md @@ -0,0 +1,48 @@ +# Performance Tests + +k6-based performance checks for the Crypto Wallet API. + +## Requirements + +- k6 installed separately on the local development machine. +- Crypto Wallet API running locally. +- A dedicated performance identity for authenticated workloads. +- PostgreSQL, Valkey, and required local infrastructure running for infrastructure benchmarks. + +## Base URL + +Default: + +```text +http://localhost:3000 +``` + +## SLOs + +The performance checks use the following local development SLO targets: + +| Flow | Availability target | Latency target | +| ---------------- | ---------------------------------- | ------------------------------------ | +| Health endpoint | >= 99% successful requests | p95 < 500 ms | +| Auth login | >= 95% successful requests | p95 < 1000 ms | +| Auth refresh | >= 99% successful requests | p95 < 500 ms | +| Auth logout | >= 99% successful requests | p95 < 500 ms | +| Bitcoin RPC read | >= 99% successful requests | p95 < 1000 ms | +| Event processing | 100% of benchmark events processed | Track p50/p95/p99 processing latency | + +These are local performance-test targets, not production availability commitments. + +## Benchmark Coverage + +Current performance coverage includes: + +- Authentication login, refresh-token rotation, and logout. +- Health endpoint scalability. +- Horizontal API-instance testing. +- Bitcoin/Esplora RPC read latency. +- PostgreSQL read/query-plan checks. +- Valkey cache throughput. +- Kafka → consumer inbox → Temporal event-processing latency. +- Event-processing retry/backlog verification. + +Portfolio, asset-query, and transaction API load tests are pending until corresponding API read endpoints are available. diff --git a/performance/k6/auth-login.js b/performance/k6/auth-login.js new file mode 100644 index 0000000..8738e37 --- /dev/null +++ b/performance/k6/auth-login.js @@ -0,0 +1,49 @@ +/* global __ENV */ + +import http from "k6/http"; +import { check, sleep } from "k6"; + +const BASE_URL = __ENV.K6_BASE_URL || "http://localhost:3000"; +const EMAIL = __ENV.K6_AUTH_EMAIL; +const PASSWORD = __ENV.K6_AUTH_PASSWORD; +const DEVICE_ID = __ENV.K6_AUTH_DEVICE_ID; + +if (!EMAIL || !PASSWORD || !DEVICE_ID) { + throw new Error("K6_AUTH_EMAIL, K6_AUTH_PASSWORD and K6_AUTH_DEVICE_ID are required"); +} + +export const options = { + scenarios: { + auth_login_smoke: { + executor: "constant-vus", + vus: 1, + duration: "8s", + }, + }, + thresholds: { + http_req_failed: ["rate<0.05"], + http_req_duration: ["p(95)<1000"], + }, +}; + +export default function () { + const response = http.post( + `${BASE_URL}/api/v1/auth/login`, + JSON.stringify({ + email: EMAIL, + password: PASSWORD, + deviceId: DEVICE_ID, + }), + { + headers: { + "Content-Type": "application/json", + }, + }, + ); + + check(response, { + "login returns 200": (res) => res.status === 200, + }); + + sleep(2); +} diff --git a/performance/k6/auth-logout.js b/performance/k6/auth-logout.js new file mode 100644 index 0000000..33ae1cf --- /dev/null +++ b/performance/k6/auth-logout.js @@ -0,0 +1,114 @@ +/* global __ENV */ + +import http from "k6/http"; +import { check } from "k6"; + +const BASE_URL = __ENV.K6_BASE_URL || "http://localhost:3000"; +const EMAIL = __ENV.K6_AUTH_EMAIL; +const PASSWORD = __ENV.K6_AUTH_PASSWORD; +const DEVICE_ID = __ENV.K6_AUTH_DEVICE_ID; + +if (!EMAIL || !PASSWORD || !DEVICE_ID) { + throw new Error("K6_AUTH_EMAIL, K6_AUTH_PASSWORD and K6_AUTH_DEVICE_ID are required"); +} + +export const options = { + scenarios: { + auth_logout_baseline: { + executor: "per-vu-iterations", + vus: 1, + iterations: 1, + maxDuration: "10s", + }, + }, + + thresholds: { + "http_req_failed{phase:logout}": ["rate<0.01"], + "http_req_duration{phase:logout}": ["p(95)<500"], + "checks{phase:logout}": ["rate>0.99"], + }, +}; + +export function setup() { + const response = http.post( + `${BASE_URL}/api/v1/auth/login`, + JSON.stringify({ + email: EMAIL, + password: PASSWORD, + deviceId: DEVICE_ID, + }), + { + headers: { + "Content-Type": "application/json", + }, + tags: { + phase: "setup", + }, + }, + ); + + check(response, { + "setup login returns 200": (res) => res.status === 200, + }); + + if (response.status !== 200) { + throw new Error(`Setup login failed with status ${response.status}`); + } + + const body = response.json(); + + if ( + !body || + !body.data || + typeof body.data.refreshToken !== "string" || + body.data.refreshToken.length === 0 + ) { + throw new Error("Setup login did not return a refresh token"); + } + + return { + refreshToken: body.data.refreshToken, + }; +} + +export default function (data) { + const response = http.post( + `${BASE_URL}/api/v1/auth/logout`, + JSON.stringify({ + refreshToken: data.refreshToken, + }), + { + headers: { + "Content-Type": "application/json", + }, + tags: { + phase: "logout", + }, + }, + ); + + const passed = check( + response, + { + "logout returns 200": (res) => res.status === 200, + "logout confirms revocation": (res) => { + const body = res.json(); + + return ( + body && + body.data && + typeof body.data.sessionId === "string" && + body.data.sessionId.length > 0 && + body.data.revoked === true + ); + }, + }, + { + phase: "logout", + }, + ); + + if (!passed) { + throw new Error(`Logout failed with status ${response.status}`); + } +} diff --git a/performance/k6/auth-refresh.js b/performance/k6/auth-refresh.js new file mode 100644 index 0000000..fe68255 --- /dev/null +++ b/performance/k6/auth-refresh.js @@ -0,0 +1,116 @@ +/* global __ENV */ + +import http from "k6/http"; +import { check, sleep } from "k6"; + +const BASE_URL = __ENV.K6_BASE_URL || "http://localhost:3000"; +const EMAIL = __ENV.K6_AUTH_EMAIL; +const PASSWORD = __ENV.K6_AUTH_PASSWORD; +const DEVICE_ID = __ENV.K6_AUTH_DEVICE_ID; + +if (!EMAIL || !PASSWORD || !DEVICE_ID) { + throw new Error("K6_AUTH_EMAIL, K6_AUTH_PASSWORD and K6_AUTH_DEVICE_ID are required"); +} + +export const options = { + scenarios: { + auth_refresh_baseline: { + executor: "constant-vus", + vus: 1, + duration: "10s", + }, + }, + + thresholds: { + "http_req_failed{phase:refresh}": ["rate<0.01"], + "http_req_duration{phase:refresh}": ["p(95)<500"], + "checks{phase:refresh}": ["rate>0.99"], + }, +}; + +export function setup() { + const response = http.post( + `${BASE_URL}/api/v1/auth/login`, + JSON.stringify({ + email: EMAIL, + password: PASSWORD, + deviceId: DEVICE_ID, + }), + { + headers: { + "Content-Type": "application/json", + }, + tags: { + phase: "setup", + }, + }, + ); + + check(response, { + "setup login returns 200": (res) => res.status === 200, + }); + + if (response.status !== 200) { + throw new Error(`Setup login failed with status ${response.status}`); + } + + const body = response.json(); + + if ( + !body || + !body.data || + typeof body.data.refreshToken !== "string" || + body.data.refreshToken.length === 0 + ) { + throw new Error("Setup login did not return a refresh token"); + } + + return { + refreshToken: body.data.refreshToken, + }; +} + +export default function (data) { + const response = http.post( + `${BASE_URL}/api/v1/auth/refresh`, + JSON.stringify({ + refreshToken: data.refreshToken, + }), + { + headers: { + "Content-Type": "application/json", + }, + tags: { + phase: "refresh", + }, + }, + ); + + const passed = check( + response, + { + "refresh returns 200": (res) => res.status === 200, + "refresh returns replacement token": (res) => { + const body = res.json(); + + return ( + body && + body.data && + typeof body.data.refreshToken === "string" && + body.data.refreshToken.length > 0 + ); + }, + }, + { + phase: "refresh", + }, + ); + + if (!passed) { + throw new Error(`Refresh failed with status ${response.status}`); + } + + data.refreshToken = response.json().data.refreshToken; + + sleep(1); +} diff --git a/performance/k6/health-horizontal.js b/performance/k6/health-horizontal.js new file mode 100644 index 0000000..c214f4f --- /dev/null +++ b/performance/k6/health-horizontal.js @@ -0,0 +1,56 @@ +import http from "k6/http"; +import { check, sleep } from "k6"; + +const API_A = __ENV.K6_API_A || "http://localhost:3000"; +const API_B = __ENV.K6_API_B || "http://localhost:3002"; + +export const options = { + scenarios: { + api_a: { + executor: "constant-vus", + vus: 5, + duration: "20s", + exec: "apiA", + }, + + api_b: { + executor: "constant-vus", + vus: 5, + duration: "20s", + exec: "apiB", + }, + }, + + thresholds: { + http_req_failed: ["rate<0.01"], + http_req_duration: ["p(95)<500"], + }, +}; + +export function apiA() { + const response = http.get(`${API_A}/health`, { + tags: { + instance: "api-a", + }, + }); + + check(response, { + "api-a health returns 200": (res) => res.status === 200, + }); + + sleep(1); +} + +export function apiB() { + const response = http.get(`${API_B}/health`, { + tags: { + instance: "api-b", + }, + }); + + check(response, { + "api-b health returns 200": (res) => res.status === 200, + }); + + sleep(1); +} diff --git a/performance/k6/health-scalability.js b/performance/k6/health-scalability.js new file mode 100644 index 0000000..3a72cd8 --- /dev/null +++ b/performance/k6/health-scalability.js @@ -0,0 +1,37 @@ +/* global __ENV */ + +import http from "k6/http"; +import { check, sleep } from "k6"; + +const BASE_URL = __ENV.K6_BASE_URL || "http://localhost:3000"; + +export const options = { + scenarios: { + health_scalability: { + executor: "ramping-vus", + startVUs: 1, + stages: [ + { duration: "10s", target: 5 }, + { duration: "10s", target: 10 }, + { duration: "10s", target: 20 }, + { duration: "10s", target: 0 }, + ], + gracefulRampDown: "5s", + }, + }, + + thresholds: { + http_req_failed: ["rate<0.01"], + http_req_duration: ["p(95)<500"], + }, +}; + +export default function () { + const response = http.get(`${BASE_URL}/health`); + + check(response, { + "health returns 200": (res) => res.status === 200, + }); + + sleep(1); +} diff --git a/performance/k6/health.js b/performance/k6/health.js new file mode 100644 index 0000000..bebb1c4 --- /dev/null +++ b/performance/k6/health.js @@ -0,0 +1,30 @@ +/* global __ENV */ + +import http from "k6/http"; +import { check, sleep } from "k6"; + +const BASE_URL = __ENV.K6_BASE_URL || "http://localhost:3000"; + +export const options = { + scenarios: { + health_smoke: { + executor: "constant-vus", + vus: 2, + duration: "10s", + }, + }, + thresholds: { + http_req_failed: ["rate<0.01"], + http_req_duration: ["p(95)<500"], + }, +}; + +export default function () { + const response = http.get(`${BASE_URL}/health`); + + check(response, { + "health returns 200": (res) => res.status === 200, + }); + + sleep(1); +} diff --git a/performance/k6/rpc-bitcoin.js b/performance/k6/rpc-bitcoin.js new file mode 100644 index 0000000..7af1259 --- /dev/null +++ b/performance/k6/rpc-bitcoin.js @@ -0,0 +1,44 @@ +import http from "k6/http"; +import { check, sleep } from "k6"; + +const RPC_URL = __ENV.K6_BITCOIN_RPC_URL || "https://blockstream.info/api"; + +export const options = { + scenarios: { + bitcoin_rpc_read: { + executor: "constant-vus", + vus: 2, + duration: "10s", + }, + }, + + thresholds: { + http_req_failed: ["rate<0.01"], + http_req_duration: ["p(95)<1000"], + checks: ["rate>0.99"], + }, +}; + +export default function () { + const response = http.get(`${RPC_URL}/fee-estimates`, { + tags: { + phase: "rpc", + provider: "bitcoin-esplora", + operation: "fee-estimates", + }, + }); + + check(response, { + "bitcoin RPC returns 200": (res) => res.status === 200, + "bitcoin RPC returns JSON": (res) => { + try { + const body = res.json(); + return body && typeof body === "object"; + } catch { + return false; + } + }, + }); + + sleep(1); +} diff --git a/performance/pgbench/read-only.sql b/performance/pgbench/read-only.sql new file mode 100644 index 0000000..0fc87b3 --- /dev/null +++ b/performance/pgbench/read-only.sql @@ -0,0 +1,7 @@ +SELECT id +FROM exchange_accounts +WHERE id = ( + SELECT id + FROM exchange_accounts + LIMIT 1 +); \ No newline at end of file From e9a69379bf4e85f793008b37a5b1e2df1f07a685 Mon Sep 17 00:00:00 2001 From: SHASHANK SHEKHAR Date: Fri, 25 Sep 2026 12:40:18 +0530 Subject: [PATCH 2/3] perf: add scalability benchmarks --- performance/k6/health-horizontal.js | 2 ++ performance/k6/rpc-bitcoin.js | 2 ++ 2 files changed, 4 insertions(+) diff --git a/performance/k6/health-horizontal.js b/performance/k6/health-horizontal.js index c214f4f..f58946e 100644 --- a/performance/k6/health-horizontal.js +++ b/performance/k6/health-horizontal.js @@ -1,3 +1,5 @@ +/* global __ENV */ + import http from "k6/http"; import { check, sleep } from "k6"; diff --git a/performance/k6/rpc-bitcoin.js b/performance/k6/rpc-bitcoin.js index 7af1259..661abcf 100644 --- a/performance/k6/rpc-bitcoin.js +++ b/performance/k6/rpc-bitcoin.js @@ -1,3 +1,5 @@ +/* global __ENV */ + import http from "k6/http"; import { check, sleep } from "k6"; From 22fe0a6231ee6bebd8ffe3bc8e7bba68da803274 Mon Sep 17 00:00:00 2001 From: SHASHANK SHEKHAR Date: Fri, 25 Sep 2026 15:17:03 +0530 Subject: [PATCH 3/3] test: complete e2e and chaos coverage --- .../api/src/__tests__/session-service.test.ts | 73 +++++ .../__tests__/transaction-engine-e2e.test.ts | 115 ++++++++ .../__tests__/wallet-lifecycle-e2e.test.ts | 77 ++++++ .../__tests__/wallet-transaction-e2e.test.ts | 249 ++++++++++++++++++ 4 files changed, 514 insertions(+) create mode 100644 packages/wallet-core/src/__tests__/transaction-engine-e2e.test.ts create mode 100644 packages/wallet-core/src/__tests__/wallet-lifecycle-e2e.test.ts create mode 100644 packages/wallet-core/src/__tests__/wallet-transaction-e2e.test.ts diff --git a/apps/api/src/__tests__/session-service.test.ts b/apps/api/src/__tests__/session-service.test.ts index 0c4ec15..b30bab2 100644 --- a/apps/api/src/__tests__/session-service.test.ts +++ b/apps/api/src/__tests__/session-service.test.ts @@ -383,6 +383,79 @@ describe("SessionService", () => { [userId], ); + await storage.disconnect(); + } + }); + it("rejects refresh after the session is revoked for device loss", async () => { + const storage = new PostgresStorage(databaseUrl); + const repository = new SessionRepository(storage); + const service = new SessionService(repository); + + const userId = randomUUID(); + const deviceId = randomUUID(); + const refreshToken = generateRefreshToken(); + + try { + await storage.connect(); + + await storage.query( + ` + INSERT INTO users (id) + VALUES ($1) + `, + [userId], + ); + + await storage.query( + ` + INSERT INTO devices ( + id, + user_id, + platform, + name + ) + VALUES ($1, $2, $3, $4) + `, + [deviceId, userId, "test", "device-loss-test"], + ); + + const session = await repository.createSession({ + userId, + deviceId, + refreshTokenHash: hashRefreshToken(refreshToken), + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + idleExpiresAt: new Date(Date.now() + 15 * 60 * 1000), + }); + + const revoked = await service.revoke(session.id, "device_lost"); + + expect(revoked).not.toBeNull(); + expect(revoked?.status).toBe("revoked"); + expect(revoked?.revokedReason).toBe("device_lost"); + + await expect( + service.refresh({ + refreshToken, + idleTimeoutMs: 15 * 60 * 1000, + }), + ).rejects.toThrow("Auth session is not active"); + } finally { + await storage.query( + ` + DELETE FROM devices + WHERE id = $1 + `, + [deviceId], + ); + + await storage.query( + ` + DELETE FROM users + WHERE id = $1 + `, + [userId], + ); + await storage.disconnect(); } }); diff --git a/packages/wallet-core/src/__tests__/transaction-engine-e2e.test.ts b/packages/wallet-core/src/__tests__/transaction-engine-e2e.test.ts new file mode 100644 index 0000000..6764c65 --- /dev/null +++ b/packages/wallet-core/src/__tests__/transaction-engine-e2e.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; + +import { DefaultTransactionEngine } from "../transaction-engine.js"; + +describe("transaction engine end-to-end", () => { + function createTransaction( + overrides: Partial<{ + id: string; + chain: "evm" | "solana" | "bitcoin"; + status: "draft" | "signed" | "submitted" | "pending" | "confirmed" | "failed"; + assetId: string; + amount: string; + }> = {}, + ) { + return { + id: overrides.id ?? "tx-day28-001", + chain: overrides.chain ?? "bitcoin", + status: overrides.status ?? "draft", + assetId: overrides.assetId ?? "btc", + amount: overrides.amount ?? "100000", + createdAt: new Date().toISOString(), + }; + } + + it("creates and tracks a transaction through the lifecycle", () => { + const engine = new DefaultTransactionEngine(); + + const created = engine.create(createTransaction()); + + expect(created.status).toBe("draft"); + expect(engine.getById(created.id)).toEqual(created); + + const signed = engine.transition(created.id, "signed"); + expect(signed.status).toBe("signed"); + + const submitted = engine.transition(created.id, "submitted"); + expect(submitted.status).toBe("submitted"); + + const pending = engine.transition(created.id, "pending"); + expect(pending.status).toBe("pending"); + + const confirmed = engine.transition(created.id, "confirmed"); + expect(confirmed.status).toBe("confirmed"); + + expect(engine.getById(created.id)?.status).toBe("confirmed"); + }); + + it("handles duplicate requests idempotently", () => { + const engine = new DefaultTransactionEngine(); + + const transaction = createTransaction({ + id: "tx-day28-idempotent", + }); + + const first = engine.createIdempotent("request-001", transaction); + const second = engine.createIdempotent("request-001", transaction); + + expect(second).toEqual(first); + expect(second.id).toBe(first.id); + }); + + it("rejects reuse of an idempotency key for a different transaction", () => { + const engine = new DefaultTransactionEngine(); + + engine.createIdempotent( + "request-002", + createTransaction({ + id: "tx-day28-original", + amount: "100000", + }), + ); + + expect(() => + engine.createIdempotent( + "request-002", + createTransaction({ + id: "tx-day28-different", + amount: "200000", + }), + ), + ).toThrow("Idempotency key already used: request-002"); + }); + + it("rejects invalid lifecycle transitions", () => { + const engine = new DefaultTransactionEngine(); + + const transaction = engine.create(createTransaction()); + + expect(() => engine.transition(transaction.id, "confirmed")).toThrow( + "Invalid transaction transition: draft -> confirmed", + ); + + expect(() => engine.transition("missing-transaction", "signed")).toThrow( + "Transaction not found: missing-transaction", + ); + }); + + it("prevents changes after terminal confirmation", () => { + const engine = new DefaultTransactionEngine(); + + const transaction = engine.create(createTransaction()); + + engine.transition(transaction.id, "signed"); + engine.transition(transaction.id, "submitted"); + engine.transition(transaction.id, "pending"); + + const confirmed = engine.transition(transaction.id, "confirmed"); + + expect(() => engine.transition(confirmed.id, "pending")).toThrow( + "Invalid transaction transition: confirmed -> pending", + ); + + expect(engine.getById(confirmed.id)?.status).toBe("confirmed"); + }); +}); diff --git a/packages/wallet-core/src/__tests__/wallet-lifecycle-e2e.test.ts b/packages/wallet-core/src/__tests__/wallet-lifecycle-e2e.test.ts new file mode 100644 index 0000000..7e56a39 --- /dev/null +++ b/packages/wallet-core/src/__tests__/wallet-lifecycle-e2e.test.ts @@ -0,0 +1,77 @@ +import { + MemorySecureStorageAdapter, + type SecureStorageOptions, +} from "@crypto-wallet/secure-storage"; +import { describe, expect, it } from "vitest"; + +import { createWallet, MNEMONIC_STORAGE_KEY } from "../index.js"; + +describe("wallet lifecycle end-to-end", () => { + const options: SecureStorageOptions = { + inactivityTimeoutMs: 60_000, + }; + + it("creates, persists, locks, unlocks, and recovers a wallet", async () => { + const adapter = new MemorySecureStorageAdapter(); + const password = "day28-test-password"; + + const firstSession = createWallet(adapter, options); + + expect(await firstSession.lifecycle.exists()).toBe(false); + + const created = await firstSession.lifecycle.create(password); + + expect(created.mnemonic).toBeTruthy(); + expect(await firstSession.lifecycle.exists()).toBe(true); + + firstSession.vault.lock(); + + expect(firstSession.vault.state.locked).toBe(true); + + const secondSession = createWallet(adapter, options); + + expect(await secondSession.lifecycle.exists()).toBe(true); + + await secondSession.vault.unlock(password); + + const storedMnemonic = secondSession.vault.get(MNEMONIC_STORAGE_KEY); + + expect(storedMnemonic).not.toBeNull(); + expect(new TextDecoder().decode(storedMnemonic!)).toBe(created.mnemonic); + }); + + it("restores a wallet from a valid recovery mnemonic", async () => { + const sourceAdapter = new MemorySecureStorageAdapter(); + const sourceSession = createWallet(sourceAdapter, options); + + const { mnemonic } = await sourceSession.lifecycle.create("source-password"); + + const restoreAdapter = new MemorySecureStorageAdapter(); + const restoreSession = createWallet(restoreAdapter, options); + + expect(await restoreSession.lifecycle.exists()).toBe(false); + + await restoreSession.lifecycle.restore("restore-password", mnemonic); + + expect(await restoreSession.lifecycle.exists()).toBe(true); + + const storedMnemonic = restoreSession.vault.get(MNEMONIC_STORAGE_KEY); + + expect(storedMnemonic).not.toBeNull(); + expect(new TextDecoder().decode(storedMnemonic!)).toBe(mnemonic); + }); + + it("rejects an invalid recovery mnemonic without persisting it", async () => { + const adapter = new MemorySecureStorageAdapter(); + const session = createWallet(adapter, options); + + await expect( + session.lifecycle.restore( + "restore-password", + "this is definitely not a valid recovery mnemonic", + ), + ).rejects.toThrow("Invalid wallet mnemonic"); + + expect(await session.lifecycle.exists()).toBe(false); + }); +}); diff --git a/packages/wallet-core/src/__tests__/wallet-transaction-e2e.test.ts b/packages/wallet-core/src/__tests__/wallet-transaction-e2e.test.ts new file mode 100644 index 0000000..0cab68e --- /dev/null +++ b/packages/wallet-core/src/__tests__/wallet-transaction-e2e.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vitest"; + +import { DefaultWalletCrypto } from "@crypto-wallet/crypto"; +import { MemorySecureStorageAdapter, createWalletVault } from "@crypto-wallet/secure-storage"; +import type { + BitcoinFeeEstimate, + BitcoinProvider, + BitcoinTransactionStatus, + BitcoinUtxo, +} from "@crypto-wallet/chain-core"; + +import { + DefaultTransactionEngine, + deriveBitcoinReceiveAddress, + MNEMONIC_STORAGE_KEY, +} from "../index.js"; + +const MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +const SOURCE_ADDRESS = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu"; + +function createUtxo(value: bigint): BitcoinUtxo { + return { + txid: "1111111111111111111111111111111111111111111111111111111111111111", + vout: 0, + value, + scriptPubKey: new Uint8Array([0x00, 0x14, ...new Array(20).fill(0)]), + confirmations: 6, + }; +} + +function createProvider() { + let status: BitcoinTransactionStatus = { + txid: "e2e-bitcoin-tx-001", + confirmed: false, + confirmations: 0, + }; + + const provider: BitcoinProvider = { + network: "bitcoin-mainnet", + + async getUtxos(address: string): Promise { + expect(address).toBe(SOURCE_ADDRESS); + + return [createUtxo(10_000n)]; + }, + + async estimateFee(): Promise { + return { + satoshisPerVbyte: 5, + }; + }, + + async broadcastTransaction(rawTransaction: Uint8Array): Promise { + expect(rawTransaction).toBeInstanceOf(Uint8Array); + + return "e2e-bitcoin-tx-001"; + }, + + async getTransactionStatus(txid: string): Promise { + return { + ...status, + txid, + }; + }, + }; + + return { + provider, + + setStatus(nextStatus: BitcoinTransactionStatus): void { + status = nextStatus; + }, + }; +} + +describe("wallet and transaction end-to-end flow", () => { + it("creates, persists, unlocks, receives and tracks a transaction", async () => { + const adapter = new MemorySecureStorageAdapter(); + + const vault = createWalletVault(adapter, { + inactivityTimeoutMs: 15 * 60 * 1000, + }); + + const crypto = new DefaultWalletCrypto(); + + // Create and persist wallet state. + await vault.unlock("test-password"); + + vault.set(MNEMONIC_STORAGE_KEY, new TextEncoder().encode(MNEMONIC)); + await vault.persist(); + + expect(await vault.hasPersistedData()).toBe(true); + + // Lock and unlock the persisted wallet. + vault.lock(); + + await vault.unlock("test-password"); + + // Derive the receive address. + const receiveAddress = await deriveBitcoinReceiveAddress( + vault, + (value) => crypto.mnemonic.toSeed(value), + { + network: "bitcoin-mainnet", + addressType: "native-segwit", + }, + ); + + expect(receiveAddress).toMatch(/^bc1/); + expect(receiveAddress).not.toBe(MNEMONIC); + + // Simulate available Bitcoin funds. + const { provider, setStatus } = createProvider(); + + const utxos = await provider.getUtxos(SOURCE_ADDRESS); + + expect(utxos).toHaveLength(1); + expect(utxos[0]?.value).toBe(10_000n); + + // Track the transaction through its lifecycle. + const engine = new DefaultTransactionEngine(); + + const transaction = engine.createIdempotent("e2e-tx-001", { + id: "e2e-transaction-001", + chain: "bitcoin", + status: "draft", + assetId: "bitcoin", + amount: "0.00005", + createdAt: new Date().toISOString(), + }); + + expect(transaction.status).toBe("draft"); + + const signed = engine.transition(transaction.id, "signed"); + expect(signed.status).toBe("signed"); + + const submitted = engine.transition(transaction.id, "submitted"); + expect(submitted.status).toBe("submitted"); + + const pending = engine.transition(transaction.id, "pending"); + expect(pending.status).toBe("pending"); + + const confirmed = engine.transition(transaction.id, "confirmed"); + expect(confirmed.status).toBe("confirmed"); + + // Verify transaction can be retrieved after completion. + const stored = engine.getById(transaction.id); + + expect(stored).toEqual(confirmed); + + // Verify idempotent retry returns the same transaction. + const duplicate = engine.createIdempotent("e2e-tx-001", { + ...transaction, + }); + + expect(duplicate).toEqual(confirmed); + + // Simulate broadcast and provider confirmation. + const rawTransaction = new Uint8Array([0x01, 0x02, 0x03]); + + const txid = await provider.broadcastTransaction(rawTransaction); + + expect(txid).toBe("e2e-bitcoin-tx-001"); + + // Simulate provider confirmation. + setStatus({ + txid: "e2e-bitcoin-tx-001", + confirmed: true, + confirmations: 6, + }); + + const status = await provider.getTransactionStatus(txid); + + expect(status).toEqual({ + txid: "e2e-bitcoin-tx-001", + confirmed: true, + confirmations: 6, + }); + }); + it("handles delayed Bitcoin confirmation", async () => { + const { provider, setStatus } = createProvider(); + + const rawTransaction = new Uint8Array([0x01, 0x02, 0x03]); + + const txid = await provider.broadcastTransaction(rawTransaction); + + expect(txid).toBe("e2e-bitcoin-tx-001"); + + const pendingStatus = await provider.getTransactionStatus(txid); + + expect(pendingStatus).toEqual({ + txid: "e2e-bitcoin-tx-001", + confirmed: false, + confirmations: 0, + }); + + setStatus({ + txid: "e2e-bitcoin-tx-001", + confirmed: true, + confirmations: 6, + }); + + const confirmedStatus = await provider.getTransactionStatus(txid); + + expect(confirmedStatus).toEqual({ + txid: "e2e-bitcoin-tx-001", + confirmed: true, + confirmations: 6, + }); + }); + it("detects a Bitcoin confirmation reorg", async () => { + const { provider, setStatus } = createProvider(); + + const rawTransaction = new Uint8Array([0x01, 0x02, 0x03]); + + const txid = await provider.broadcastTransaction(rawTransaction); + + setStatus({ + txid, + confirmed: true, + confirmations: 6, + }); + + const confirmedStatus = await provider.getTransactionStatus(txid); + + expect(confirmedStatus).toEqual({ + txid, + confirmed: true, + confirmations: 6, + }); + + // Simulate a chain reorganization removing the confirmation. + setStatus({ + txid, + confirmed: false, + confirmations: 0, + }); + + const reorgedStatus = await provider.getTransactionStatus(txid); + + expect(reorgedStatus).toEqual({ + txid, + confirmed: false, + confirmations: 0, + }); + }); +});