From 02036f8055d4348210ade40cdc8ffc6e77c51d63 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 29 Jul 2026 18:06:57 -0700 Subject: [PATCH 1/9] feat: add correlated wallet performance telemetry --- .../__tests/ExpressTransportHardening.test.ts | 49 +- .../auth-express-middleware/src/index.ts | 87 +++- packages/sdk/docs/telemetry.md | 66 +++ packages/sdk/src/telemetry/Telemetry.ts | 457 ++++++++++++++++-- packages/sdk/src/telemetry/TraceContext.ts | 36 ++ .../src/telemetry/WalletInstrumentation.ts | 114 +++++ .../src/telemetry/__tests/Telemetry.test.ts | 119 ----- .../src/telemetry/__tests__/Telemetry.test.ts | 333 +++++++++++++ .../telemetry/__tests__/TraceContext.test.ts | 30 ++ .../__tests__/WalletInstrumentation.test.ts | 68 +++ packages/sdk/src/telemetry/index.ts | 2 + packages/wallet/wallet-toolbox/src/Wallet.ts | 43 +- .../src/WalletPermissionsManager.ts | 98 +++- ...WalletPermissionsManager.callbacks.test.ts | 55 ++- .../WalletPermissionsManager.fixtures.ts | 3 +- .../src/sdk/WalletServices.interfaces.ts | 21 +- .../wallet-toolbox/src/services/Services.ts | 55 ++- .../chaintracker/ChaintracksChainTracker.ts | 68 ++- .../__tests/ChaintracksChainTracker.test.ts | 40 ++ .../methods/completeSignedTransaction.ts | 23 +- .../src/signer/methods/createAction.ts | 94 +++- .../src/signer/methods/signAction.ts | 90 +++- .../wallet-toolbox/src/storage/StorageKnex.ts | 443 ++++++++++------- .../src/storage/StorageReader.ts | 103 ++-- .../__test/StorageKnex.telemetry.test.ts | 69 +++ .../GenerateChange/generateChangeSdk.test.ts | 54 ++- .../src/storage/methods/createAction.ts | 2 +- .../src/storage/methods/generateChange.ts | 86 +++- .../src/storage/remoting/StorageClient.ts | 161 +++--- .../src/storage/remoting/StorageClientBase.ts | 54 ++- .../src/storage/remoting/StorageMobile.ts | 108 +++-- .../src/storage/remoting/StorageServer.ts | 124 ++++- .../__test/StorageClient.telemetry.test.ts | 71 +++ .../utility/__tests__/traceContext.test.ts | 30 ++ .../src/utility/traceContext.ts | 36 ++ 35 files changed, 2721 insertions(+), 571 deletions(-) create mode 100644 packages/sdk/src/telemetry/TraceContext.ts create mode 100644 packages/sdk/src/telemetry/WalletInstrumentation.ts delete mode 100644 packages/sdk/src/telemetry/__tests/Telemetry.test.ts create mode 100644 packages/sdk/src/telemetry/__tests__/Telemetry.test.ts create mode 100644 packages/sdk/src/telemetry/__tests__/TraceContext.test.ts create mode 100644 packages/sdk/src/telemetry/__tests__/WalletInstrumentation.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/__test/StorageKnex.telemetry.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/utility/__tests__/traceContext.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/utility/traceContext.ts diff --git a/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts b/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts index bbc86b317..6290857f9 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts @@ -15,7 +15,8 @@ function responseMock(): any { json: jest.fn(), text: jest.fn(), end: jest.fn(), - sendFile: jest.fn() + sendFile: jest.fn(), + once: jest.fn() } response.status.mockReturnValue(response) response.set.mockReturnValue(response) @@ -740,4 +741,50 @@ describe('ExpressTransport hardening', () => { ) expect(JSON.stringify(debug.mock.calls)).not.toContain('/public') }) + + it('emits a traceparent-linked auth span without request secrets', async () => { + const events: any[] = [] + const middleware = createAuthMiddleware({ + wallet: new MockWallet(new PrivateKey(1)), + allowUnauthenticated: true, + telemetry: { + sink: { + capture: event => events.push(event) + }, + traceIdFactory: () => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanIdFactory: () => 'bbbbbbbbbbbbbbbb' + } + }) + const req = { + path: '/private/customer-record', + method: 'GET', + headers: { + authorization: 'Bearer never-report-this', + traceparent: '00-0123456789abcdef0123456789abcdef-fedcba9876543210-01' + } + } as any + const res = responseMock() + const next = jest.fn() + + middleware(req, res, next) + await new Promise(resolve => setImmediate(resolve)) + + expect(next).toHaveBeenCalled() + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + type: 'span', + name: 'wallet.auth.middleware', + traceId: '0123456789abcdef0123456789abcdef', + parentSpanId: 'fedcba9876543210', + spanStatus: 'ok', + attributes: { + 'http.request.method': 'GET', + 'auth.handshake': false, + 'auth.signed_request': false, + 'auth.disposition': 'continued' + } + }) + expect(JSON.stringify(events)).not.toContain('customer-record') + expect(JSON.stringify(events)).not.toContain('never-report-this') + }) }) diff --git a/packages/middleware/auth-express-middleware/src/index.ts b/packages/middleware/auth-express-middleware/src/index.ts index 6c9293b02..d49190f7e 100644 --- a/packages/middleware/auth-express-middleware/src/index.ts +++ b/packages/middleware/auth-express-middleware/src/index.ts @@ -5,10 +5,12 @@ import { Peer, SessionManager, PublicKey, + Telemetry, type AsyncSessionManager, type AuthMessage, type PubKeyHex, type RequestedCertificateSet, + type TelemetryConfig, type Transport, type VerifiableCertificate, type WalletInterface @@ -34,6 +36,26 @@ const WELL_KNOWN_AUTH_PATH = '/.well-known/auth' const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 const DEFAULT_MAX_PENDING_REQUESTS = 1_000 const MAX_AUTH_HEADER_LENGTH = 4_096 +const TRACEPARENT_PATTERN = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i + +function parseTraceparent( + value: unknown +): { traceId: string; spanId: string; traceFlags: number } | undefined { + if (typeof value !== 'string' || value.length > 128) return undefined + const match = TRACEPARENT_PATTERN.exec(value.trim()) + if ( + match == null || + /^0{32}$/.test(match[1]) || + /^0{16}$/.test(match[2]) + ) { + return undefined + } + return { + traceId: match[1].toLowerCase(), + spanId: match[2].toLowerCase(), + traceFlags: Number.parseInt(match[3], 16) + } +} interface PendingHandle { res: Response @@ -100,6 +122,12 @@ export interface AuthMiddlewareOptions { * 30-second timeout and 1,000 concurrent request records per process. */ transportLimits?: Partial + + /** + * Optional provider-neutral authentication timing. Header values, signatures, + * certificate contents, wallet data, and peer identities are never emitted. + */ + telemetry?: TelemetryConfig } class AuthProtocolError extends Error { @@ -1318,7 +1346,8 @@ export function createAuthMiddleware( onCertificatesReceived, logger, logLevel, - transportLimits + transportLimits, + telemetry: telemetryConfig } = options if (wallet === null || typeof wallet !== 'object') { @@ -1362,6 +1391,7 @@ export function createAuthMiddleware( const peer = new Peer(wallet, transport, certificatesToRequest, sessionMgr) transport.setPeer(peer) + const telemetry = new Telemetry(telemetryConfig) return (req: AuthRequest, res: Response, next: NextFunction) => { if (logger && logLevel && isLogLevelEnabled(logLevel, 'debug')) { @@ -1371,6 +1401,59 @@ export function createAuthMiddleware( hasAuthRequestId: typeof req.headers['x-bsv-auth-request-id'] === 'string' }) } - void transport.handleIncomingRequest(req, res, next, onCertificatesReceived).catch(next) + if (!telemetry.enabled) { + void transport.handleIncomingRequest(req, res, next, onCertificatesReceived).catch(next) + return + } + + const parent = telemetry.contextFor(req) ?? parseTraceparent(req.headers.traceparent) + const span = telemetry.startSpan('wallet.auth.middleware', { + component: 'auth-express-middleware', + kind: 'server', + parent, + carrier: req, + attributes: { + 'http.request.method': req.method, + 'auth.handshake': req.path === WELL_KNOWN_AUTH_PATH, + 'auth.signed_request': typeof req.headers['x-bsv-auth-request-id'] === 'string' + } + }) + span.bind(req) + let ended = false + const end = ( + status: 'ok' | 'error' | 'cancelled', + error?: unknown, + disposition?: string + ): void => { + if (ended) return + ended = true + span.end({ + status, + error, + attributes: { + ...(disposition == null ? {} : { 'auth.disposition': disposition }), + ...(res.statusCode > 0 ? { 'http.response.status_code': res.statusCode } : {}) + } + }) + } + const tracedNext = ((argument?: unknown) => { + end(argument instanceof Error ? 'error' : 'ok', argument, 'continued') + if (argument === undefined) next() + else next(argument) + }) as NextFunction + + res.once('finish', () => { + end(res.statusCode >= 500 ? 'error' : 'ok', undefined, 'responded') + }) + res.once('close', () => { + end(res.writableEnded ? 'ok' : 'cancelled', undefined, 'connection_closed') + }) + + void transport + .handleIncomingRequest(req, res, tracedNext, onCertificatesReceived) + .catch(error => { + end('error', error, 'middleware_error') + next(error) + }) } } diff --git a/packages/sdk/docs/telemetry.md b/packages/sdk/docs/telemetry.md index 9a2026f7e..a905088a5 100644 --- a/packages/sdk/docs/telemetry.md +++ b/packages/sdk/docs/telemetry.md @@ -32,6 +32,66 @@ const resolver = new LookupResolver({ telemetry }) `capture` never throws into SDK behavior. Synchronous and asynchronous sink failures are isolated. Events are sanitized before `beforeSend` and sanitized again afterward, so consumer enrichment cannot bypass the redaction boundary. +Sinks should only enqueue work: telemetry export must not be awaited on a +wallet, bridge, authentication, or storage request path. + +Long-lived hosts can pass `enabled: () => currentDiagnosticsPreference` to +honor runtime opt-in/opt-out changes without reconstructing the wallet. + +## Timed and correlated work + +`Telemetry.startSpan()` and `Telemetry.withSpan()` record high-resolution +monotonic durations while retaining wall-clock start times. Spans use +W3C-compatible 32-character trace IDs, 16-character span IDs, explicit +parentage, kinds, and status. Sibling work can run in parallel without losing +its parent: + +```ts +import { Telemetry, formatTraceparent, instrumentWallet } from '@bsv/sdk' + +const dispatcher = new Telemetry(telemetry) +const walletAtBoundary = instrumentWallet(wallet, dispatcher, { + component: 'react-native-bridge', + kind: 'server' +}) + +await dispatcher.withSpan( + 'wallet.storage.rpc', + { component: 'storage-client', kind: 'client', carrier: args }, + async span => { + const traceparent = formatTraceparent(span.context) + await fetch(storageUrl, { + method: 'POST', + headers: traceparent == null ? {} : { traceparent } + }) + } +) +``` + +The first BRC-100 argument object is an explicit context carrier. Consumers and +Wallet Toolbox layers can use `contextFor`, `bindContext`, and `linkContext` +without modifying or serializing wallet arguments. This is the portable +browser and React Native propagation mechanism. Node hosts should provide a +`TelemetryContextManager` backed by `AsyncLocalStorage` so database queries and +other asynchronous descendants inherit the active request automatically. + +`formatTraceparent` and `parseTraceparent` propagate context over HTTP using +W3C Trace Context. Invalid, unsupported-version, oversized, and all-zero +headers are ignored. + +## Runtime measurements + +Hosts may provide `TelemetryRuntimeMetrics` to add privacy-safe CPU, garbage +collection, heap, or event-loop deltas to completed spans. Availability varies +by runtime: wall-clock and monotonic duration are always available, while a +browser or React Native runtime must not label inferred waiting time as exact +CPU or GC time. Metric names and units should be stable scalar attributes, such +as `runtime.cpu_ms`, `runtime.gc_ms`, and `runtime.event_loop_utilization`. + +Telemetry work has its own cost. Exporters should measure queue time, encoding +time, batch size, dropped-event counts, retry counts, and export latency as +separate telemetry events. Those exporter events must be prevented from +recursively reporting themselves. The event schema intentionally permits scalar attributes only. Names and values associated with passwords, private or presentation keys, recovery material, @@ -43,6 +103,12 @@ Redaction is a defense in depth measure, not permission to attach sensitive objects. Producers and consumers must never add request payloads, keys, wallet snapshots, encrypted factors, or transaction material to telemetry. +High-cardinality identity, origin, URL, query-text, and transaction attributes +are not emitted by the generic instrumentation. A wallet vendor may attach a +bounded origin category or one-way pseudonymous cohort under its own consent, +retention, and privacy policy, but must not put raw credentials, request +payloads, SQL, authentication headers, or wallet data into an event. + `LookupResolver.queryDetailed()` returns the ordinary lookup answer together with host-settlement evidence. Security-sensitive code can use the counts to distinguish an authoritative empty result from timeouts, malformed responses, diff --git a/packages/sdk/src/telemetry/Telemetry.ts b/packages/sdk/src/telemetry/Telemetry.ts index 54bd7c71f..003c4976b 100644 --- a/packages/sdk/src/telemetry/Telemetry.ts +++ b/packages/sdk/src/telemetry/Telemetry.ts @@ -4,6 +4,15 @@ import { toHex } from '../primitives/utils.js' export type TelemetrySeverity = 'debug' | 'info' | 'warn' | 'error' export type TelemetryAttributeValue = string | number | boolean +export type TelemetryEventType = 'event' | 'span' +export type TelemetrySpanKind = 'internal' | 'client' | 'server' +export type TelemetrySpanStatus = 'ok' | 'error' | 'cancelled' + +export interface TelemetrySpanContext { + traceId: string + spanId: string + traceFlags?: number +} export interface TelemetryError { name: string @@ -24,7 +33,15 @@ export interface TelemetryEvent { component: string severity: TelemetrySeverity timestamp: number + type?: TelemetryEventType correlationId?: string + traceId?: string + spanId?: string + parentSpanId?: string + spanKind?: TelemetrySpanKind + spanStatus?: TelemetrySpanStatus + startTimestamp?: number + durationMs?: number attributes?: Readonly> error?: TelemetryError } @@ -33,7 +50,15 @@ export interface TelemetryEventInput { name: string component: string severity?: TelemetrySeverity + type?: TelemetryEventType correlationId?: string + traceId?: string + spanId?: string + parentSpanId?: string + spanKind?: TelemetrySpanKind + spanStatus?: TelemetrySpanStatus + startTimestamp?: number + durationMs?: number attributes?: Readonly> error?: unknown } @@ -46,17 +71,52 @@ export interface TelemetrySink { capture: (event: Readonly) => void | Promise } +/** + * Optional host integration for asynchronous span context propagation. + * + * Node applications can adapt AsyncLocalStorage. Browser and React Native + * consumers can instead use the explicit carrier helpers on `Telemetry`. + */ +export interface TelemetryContextManager { + active: () => TelemetrySpanContext | undefined + run: (context: TelemetrySpanContext, callback: () => T) => T +} + +/** + * Optional runtime sampler. Counter and gauge semantics are host-defined; the + * provider returns the already-derived, privacy-safe attributes to append to a + * completed span. + */ +export interface TelemetryRuntimeMetrics { + snapshot: () => unknown + diff: (start: unknown, end: unknown) => Readonly> +} + export interface TelemetryConfig { - /** No events are emitted unless a sink is supplied and enabled is not false. */ + /** + * No events are emitted unless a sink is supplied and this is true. A + * predicate lets long-lived hosts honor a diagnostics preference without + * reconstructing every instrumented component. + */ sink?: TelemetrySink - enabled?: boolean + enabled?: boolean | (() => boolean) minimumSeverity?: TelemetrySeverity /** Error stacks are omitted by default. Enable only for a trusted sink. */ includeErrorStack?: boolean /** Injectable clock for tests and host applications. */ now?: () => number + /** Monotonic high-resolution clock used for span durations. */ + highResolutionNow?: () => number /** Injectable correlation-id factory for distributed tracing. */ correlationIdFactory?: () => string + /** Injectable trace-id factory. Must return 32 lowercase hexadecimal characters. */ + traceIdFactory?: () => string + /** Injectable span-id factory. Must return 16 lowercase hexadecimal characters. */ + spanIdFactory?: () => string + /** Optional async context propagation supplied by the host runtime. */ + contextManager?: TelemetryContextManager + /** Optional CPU, GC, heap, and event-loop sampler supplied by the host runtime. */ + runtimeMetrics?: TelemetryRuntimeMetrics /** * Last-mile event filtering or enrichment. The returned event is sanitized * again, so enrichment cannot bypass the secret-redaction boundary. @@ -64,12 +124,30 @@ export interface TelemetryConfig { beforeSend?: (event: Readonly) => TelemetryEvent | null | undefined } +export interface TelemetrySpanOptions { + component: string + kind?: TelemetrySpanKind + parent?: TelemetrySpanContext + carrier?: object + correlationId?: string + attributes?: Readonly> +} + +export interface TelemetrySpanEndOptions { + status?: TelemetrySpanStatus + severity?: TelemetrySeverity + attributes?: Readonly> + error?: unknown +} + const REDACTED = '[REDACTED]' const MAX_ATTRIBUTES = 64 const MAX_ATTRIBUTE_LENGTH = 512 const MAX_ERROR_LENGTH = 2048 const MAX_STACK_LENGTH = 8192 let fallbackCorrelationSequence = 0 +const carrierContexts = new WeakMap() +const synchronousContextStack: TelemetrySpanContext[] = [] const SENSITIVE_TERMS = [ 'password', @@ -96,10 +174,7 @@ const SENSITIVE_TERMS = [ const SENSITIVE_LABEL_PATTERNS = SENSITIVE_TERMS.map(term => { const flexibleTerm = term.replace(/key|token|time/g, match => `[_ -]?${match}`) - return new RegExp( - String.raw`(${flexibleTerm}\s*["'=:]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}]+)`, - 'gi' - ) + return new RegExp(String.raw`(${flexibleTerm}\s*["'=:]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}]+)`, 'gi') }) const BEARER_TOKEN = /\bBearer\s+[a-z0-9._~+/=-]+/gi @@ -120,7 +195,7 @@ const severityRank: Record = { error: 40 } -function truncate (value: string, maxLength: number): string { +function truncate(value: string, maxLength: number): string { if (value.length <= maxLength) return value return `${value.slice(0, maxLength)}…` } @@ -129,7 +204,7 @@ function truncate (value: string, maxLength: number): string { * Removes common secret encodings from diagnostic text. Unlabelled 256-bit * hex values are redacted because they may be private or presentation keys. */ -export function sanitizeTelemetryText (value: string, maxLength: number = MAX_ERROR_LENGTH): string { +export function sanitizeTelemetryText(value: string, maxLength: number = MAX_ERROR_LENGTH): string { // Bound sanitizer work before applying regular expressions. Any suffix past // this window cannot reach the emitted value, even after redaction. const bounded = value.slice(0, Math.max(maxLength * 2, maxLength + 256)) @@ -149,17 +224,35 @@ export function sanitizeTelemetryText (value: string, maxLength: number = MAX_ER ) } -function sanitizeName (value: unknown, fallback: string): string { +function sanitizeName(value: unknown, fallback: string): string { if (typeof value !== 'string' || value.trim().length === 0) return fallback return truncate(value.trim().replace(/[^a-zA-Z0-9_.:-]/g, '_'), 160) } -function sanitizeCorrelationId (value: unknown): string | undefined { +function sanitizeCorrelationId(value: unknown): string | undefined { if (typeof value !== 'string' || value.length === 0) return undefined return truncate(value.replace(/[^a-zA-Z0-9_.:-]/g, '_'), 128) } -function sanitizeAttributeValue (name: string, value: unknown): TelemetryAttributeValue | undefined { +function sanitizeTraceId(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const normalized = value.toLowerCase() + if (!/^[0-9a-f]{32}$/.test(normalized) || /^0{32}$/.test(normalized)) return undefined + return normalized +} + +function sanitizeSpanId(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const normalized = value.toLowerCase() + if (!/^[0-9a-f]{16}$/.test(normalized) || /^0{16}$/.test(normalized)) return undefined + return normalized +} + +function sanitizeDuration(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined +} + +function sanitizeAttributeValue(name: string, value: unknown): TelemetryAttributeValue | undefined { const normalizedName = name.toLowerCase().replaceAll(/[_ .-]/g, '') if (SENSITIVE_TERMS.some(term => normalizedName.includes(term))) return REDACTED if (typeof value === 'string') return sanitizeTelemetryText(value, MAX_ATTRIBUTE_LENGTH) @@ -169,7 +262,7 @@ function sanitizeAttributeValue (name: string, value: unknown): TelemetryAttribu return REDACTED } -function sanitizeAttributes ( +function sanitizeAttributes( attributes: Readonly> | undefined ): Readonly> | undefined { if (attributes == null) return undefined @@ -187,16 +280,16 @@ function sanitizeAttributes ( return count > 0 ? safe : undefined } -function sanitizeError (error: unknown, includeStack: boolean): TelemetryError | undefined { +function sanitizeError(error: unknown, includeStack: boolean): TelemetryError | undefined { if (error == null) return undefined if (error instanceof Error) { const withCode = error as Error & { code?: unknown } - const code = typeof withCode.code === 'string' - ? sanitizeTelemetryText(withCode.code, 120) - : undefined - const stack = includeStack && typeof error.stack === 'string' - ? sanitizeTelemetryText(error.stack, MAX_STACK_LENGTH) - : undefined + const code = + typeof withCode.code === 'string' ? sanitizeTelemetryText(withCode.code, 120) : undefined + const stack = + includeStack && typeof error.stack === 'string' + ? sanitizeTelemetryText(error.stack, MAX_STACK_LENGTH) + : undefined return { name: sanitizeName(error.name, 'Error'), message: sanitizeTelemetryText(error.message.length > 0 ? error.message : 'Unknown error'), @@ -204,38 +297,194 @@ function sanitizeError (error: unknown, includeStack: boolean): TelemetryError | ...(stack !== undefined ? { stack } : {}) } } + if (typeof error === 'object') { + const candidate = error as { + name?: unknown + message?: unknown + code?: unknown + stack?: unknown + } + const code = + typeof candidate.code === 'string' ? sanitizeTelemetryText(candidate.code, 120) : undefined + const stack = + includeStack && typeof candidate.stack === 'string' + ? sanitizeTelemetryText(candidate.stack, MAX_STACK_LENGTH) + : undefined + return { + name: sanitizeName(candidate.name, 'Error'), + message: sanitizeTelemetryText( + typeof candidate.message === 'string' && candidate.message.length > 0 + ? candidate.message + : 'Unknown error' + ), + ...(code !== undefined ? { code } : {}), + ...(stack !== undefined ? { stack } : {}) + } + } return { name: 'Error', message: sanitizeTelemetryText(typeof error === 'string' ? error : 'Unknown error') } } -function sanitizeEvent ( +function sanitizeEvent( event: TelemetryEventInput | TelemetryEvent, now: () => number, includeErrorStack: boolean ): TelemetryEvent { const severity = event.severity ?? 'info' const correlationId = sanitizeCorrelationId(event.correlationId) + const traceId = sanitizeTraceId(event.traceId) + const spanId = sanitizeSpanId(event.spanId) + const parentSpanId = sanitizeSpanId(event.parentSpanId) + const startTimestamp = sanitizeDuration(event.startTimestamp) + const durationMs = sanitizeDuration(event.durationMs) const attributes = sanitizeAttributes(event.attributes) const error = sanitizeError(event.error, includeErrorStack) return { name: sanitizeName(event.name, 'unknown'), component: sanitizeName(event.component, 'unknown'), severity, - timestamp: typeof (event as TelemetryEvent).timestamp === 'number' && + timestamp: + typeof (event as TelemetryEvent).timestamp === 'number' && Number.isFinite((event as TelemetryEvent).timestamp) - ? (event as TelemetryEvent).timestamp - : now(), - ...(correlationId !== undefined - ? { correlationId } + ? (event as TelemetryEvent).timestamp + : now(), + ...(event.type === 'span' || event.type === 'event' ? { type: event.type } : {}), + ...(correlationId !== undefined ? { correlationId } : {}), + ...(traceId !== undefined ? { traceId } : {}), + ...(spanId !== undefined ? { spanId } : {}), + ...(parentSpanId !== undefined ? { parentSpanId } : {}), + ...(event.spanKind === 'internal' || event.spanKind === 'client' || event.spanKind === 'server' + ? { spanKind: event.spanKind } : {}), - ...(attributes !== undefined - ? { attributes } + ...(event.spanStatus === 'ok' || + event.spanStatus === 'error' || + event.spanStatus === 'cancelled' + ? { spanStatus: event.spanStatus } : {}), - ...(error !== undefined - ? { error } - : {}) + ...(startTimestamp !== undefined ? { startTimestamp } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + ...(attributes !== undefined ? { attributes } : {}), + ...(error !== undefined ? { error } : {}) + } +} + +function defaultHighResolutionNow(): number { + const perf = globalThis.performance + return perf != null && typeof perf.now === 'function' ? perf.now() : Date.now() +} + +function randomTraceId(): string { + return toHex(Random(16)) +} + +function randomSpanId(): string { + return toHex(Random(8)) +} + +function mergeAttributes( + first: Readonly> | undefined, + second: Readonly> | undefined, + third: Readonly> | undefined +): Readonly> | undefined { + if (first == null && second == null && third == null) return undefined + return { + ...first, + ...second, + ...third + } +} + +/** + * A single timed operation. Ending a span is idempotent and cannot throw. + */ +export class TelemetrySpan { + readonly context: TelemetrySpanContext + readonly parentSpanId?: string + + private readonly startedAt: number + private readonly startedAtHighResolution: number + private readonly runtimeStart: unknown + private ended = false + + constructor( + private readonly telemetry: Telemetry, + readonly name: string, + readonly component: string, + readonly kind: TelemetrySpanKind, + parent: TelemetrySpanContext | undefined, + private readonly correlationId: string, + private readonly initialAttributes?: Readonly> + ) { + const now = telemetry.wallClock() + this.startedAt = now() + this.startedAtHighResolution = telemetry.monotonicClock()() + this.context = { + traceId: parent?.traceId ?? telemetry.createTraceId(), + spanId: telemetry.createSpanId(), + ...(parent?.traceFlags !== undefined ? { traceFlags: parent.traceFlags } : {}) + } + this.parentSpanId = parent?.spanId + this.runtimeStart = telemetry.runtimeSnapshot() + } + + bind(carrier: object): void { + this.telemetry.bindContext(carrier, this.context) + } + + child(name: string, options: Omit): TelemetrySpan { + return this.telemetry.startSpan(name, { + ...options, + parent: this.context, + correlationId: options.correlationId ?? this.correlationId + }) + } + + capture( + name: string, + attributes?: Readonly>, + severity: TelemetrySeverity = 'info' + ): void { + this.telemetry.capture({ + name, + component: this.component, + severity, + type: 'event', + correlationId: this.correlationId, + traceId: this.context.traceId, + spanId: this.context.spanId, + parentSpanId: this.parentSpanId, + attributes + }) + } + + end(options: TelemetrySpanEndOptions = {}): void { + if (this.ended) return + this.ended = true + + const runtimeAttributes = this.telemetry.runtimeDiff( + this.runtimeStart, + this.telemetry.runtimeSnapshot() + ) + const durationMs = Math.max(0, this.telemetry.monotonicClock()() - this.startedAtHighResolution) + const status = options.status ?? (options.error == null ? 'ok' : 'error') + this.telemetry.capture({ + name: this.name, + component: this.component, + severity: options.severity ?? (status === 'error' ? 'error' : 'info'), + type: 'span', + correlationId: this.correlationId, + traceId: this.context.traceId, + spanId: this.context.spanId, + parentSpanId: this.parentSpanId, + spanKind: this.kind, + spanStatus: status, + startTimestamp: this.startedAt, + durationMs, + attributes: mergeAttributes(this.initialAttributes, runtimeAttributes, options.attributes), + error: options.error + }) } } @@ -249,15 +498,29 @@ function sanitizeEvent ( export class Telemetry { private readonly config: TelemetryConfig - constructor (config: TelemetryConfig = {}) { + constructor(config: TelemetryConfig = {}) { this.config = config } - get enabled (): boolean { - return this.config.enabled !== false && this.config.sink != null + get enabled(): boolean { + if (this.config.sink == null) return false + if (typeof this.config.enabled !== 'function') return this.config.enabled !== false + try { + return this.config.enabled() + } catch { + return false + } + } + + wallClock(): () => number { + return this.config.now ?? Date.now + } + + monotonicClock(): () => number { + return this.config.highResolutionNow ?? defaultHighResolutionNow } - createCorrelationId (): string { + createCorrelationId(): string { try { const custom = this.config.correlationIdFactory?.() if (typeof custom === 'string' && custom.length > 0) { @@ -270,7 +533,127 @@ export class Telemetry { } } - capture (input: TelemetryEventInput): void { + createTraceId(): string { + try { + const custom = sanitizeTraceId(this.config.traceIdFactory?.()) + return custom ?? randomTraceId() + } catch { + return randomTraceId() + } + } + + createSpanId(): string { + try { + const custom = sanitizeSpanId(this.config.spanIdFactory?.()) + return custom ?? randomSpanId() + } catch { + return randomSpanId() + } + } + + activeContext(): TelemetrySpanContext | undefined { + try { + return ( + this.config.contextManager?.active() ?? + synchronousContextStack[synchronousContextStack.length - 1] + ) + } catch { + return synchronousContextStack[synchronousContextStack.length - 1] + } + } + + contextFor(carrier: object | undefined): TelemetrySpanContext | undefined { + if (carrier == null) return undefined + return carrierContexts.get(carrier) + } + + bindContext(carrier: object, context: TelemetrySpanContext): void { + carrierContexts.set(carrier, context) + } + + linkContext(source: object | undefined, target: object | undefined): void { + if (source == null || target == null) return + const context = this.contextFor(source) + if (context != null) this.bindContext(target, context) + } + + startSpan(name: string, options: TelemetrySpanOptions): TelemetrySpan { + const parent = options.parent ?? this.contextFor(options.carrier) ?? this.activeContext() + const correlationId = options.correlationId ?? parent?.traceId ?? this.createCorrelationId() + const span = new TelemetrySpan( + this, + name, + options.component, + options.kind ?? 'internal', + parent, + correlationId, + options.attributes + ) + if (options.carrier != null) span.bind(options.carrier) + return span + } + + withSpan( + name: string, + options: TelemetrySpanOptions, + callback: (span: TelemetrySpan) => T + ): T { + const span = this.startSpan(name, options) + const invoke = (): T => callback(span) + let result: T + try { + if (this.config.contextManager != null) { + result = this.config.contextManager.run(span.context, invoke) + } else { + synchronousContextStack.push(span.context) + try { + result = invoke() + } finally { + synchronousContextStack.pop() + } + } + } catch (error) { + span.end({ status: 'error', error }) + throw error + } + + if (result != null && typeof (result as PromiseLike).then === 'function') { + return Promise.resolve(result as PromiseLike).then( + value => { + span.end() + return value + }, + error => { + span.end({ status: 'error', error }) + throw error + } + ) as T + } + + span.end() + return result + } + + runtimeSnapshot(): unknown { + try { + return this.config.runtimeMetrics?.snapshot() + } catch { + return undefined + } + } + + runtimeDiff( + start: unknown, + end: unknown + ): Readonly> | undefined { + try { + return this.config.runtimeMetrics?.diff(start, end) + } catch { + return undefined + } + } + + capture(input: TelemetryEventInput): void { if (!this.enabled) return const minimum = this.config.minimumSeverity ?? 'info' const severity = input.severity ?? 'info' @@ -293,7 +676,9 @@ export class Telemetry { try { const result = this.config.sink?.capture(event) if (result != null && typeof (result as PromiseLike).then === 'function') { - void Promise.resolve(result).catch(() => { /* telemetry must never break callers */ }) + void Promise.resolve(result).catch(() => { + /* telemetry must never break callers */ + }) } } catch { // Telemetry must never break wallet or network behavior. diff --git a/packages/sdk/src/telemetry/TraceContext.ts b/packages/sdk/src/telemetry/TraceContext.ts new file mode 100644 index 000000000..03603a31c --- /dev/null +++ b/packages/sdk/src/telemetry/TraceContext.ts @@ -0,0 +1,36 @@ +import type { TelemetrySpanContext } from './Telemetry.js' + +const TRACEPARENT_VERSION = '00' +const TRACEPARENT_PATTERN = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i + +function validTraceId(value: string): boolean { + return /^[0-9a-f]{32}$/i.test(value) && !/^0{32}$/.test(value) +} + +function validSpanId(value: string): boolean { + return /^[0-9a-f]{16}$/i.test(value) && !/^0{16}$/.test(value) +} + +/** + * Encodes the current span using the W3C Trace Context `traceparent` format. + */ +export function formatTraceparent(context: TelemetrySpanContext): string | undefined { + if (!validTraceId(context.traceId) || !validSpanId(context.spanId)) return undefined + const flags = Math.max(0, Math.min(255, context.traceFlags ?? 1)) + return `${TRACEPARENT_VERSION}-${context.traceId.toLowerCase()}-${context.spanId.toLowerCase()}-${flags.toString(16).padStart(2, '0')}` +} + +/** + * Parses a W3C version-00 `traceparent`. Malformed, future-version, and all-zero + * identifiers are ignored so untrusted headers cannot break request handling. + */ +export function parseTraceparent(value: unknown): TelemetrySpanContext | undefined { + if (typeof value !== 'string' || value.length > 128) return undefined + const match = TRACEPARENT_PATTERN.exec(value.trim()) + if (match == null || !validTraceId(match[1]) || !validSpanId(match[2])) return undefined + return { + traceId: match[1].toLowerCase(), + spanId: match[2].toLowerCase(), + traceFlags: Number.parseInt(match[3], 16) + } +} diff --git a/packages/sdk/src/telemetry/WalletInstrumentation.ts b/packages/sdk/src/telemetry/WalletInstrumentation.ts new file mode 100644 index 000000000..bf55b129c --- /dev/null +++ b/packages/sdk/src/telemetry/WalletInstrumentation.ts @@ -0,0 +1,114 @@ +import type { + OriginatorDomainNameStringUnder250Bytes, + WalletInterface +} from '../wallet/Wallet.interfaces.js' +import { + Telemetry, + type TelemetryAttributeValue, + type TelemetryConfig, + type TelemetrySpanKind +} from './Telemetry.js' + +const walletMethodNames = new Set([ + 'getPublicKey', + 'revealCounterpartyKeyLinkage', + 'revealSpecificKeyLinkage', + 'encrypt', + 'decrypt', + 'createHmac', + 'verifyHmac', + 'createSignature', + 'verifySignature', + 'createAction', + 'signAction', + 'abortAction', + 'listActions', + 'internalizeAction', + 'listOutputs', + 'relinquishOutput', + 'acquireCertificate', + 'listCertificates', + 'proveCertificate', + 'relinquishCertificate', + 'discoverByIdentityKey', + 'discoverByAttributes', + 'isAuthenticated', + 'waitForAuthentication', + 'getHeight', + 'getHeaderForHeight', + 'getNetwork', + 'getVersion' +]) + +export interface WalletInstrumentationOptions { + component?: string + spanNamePrefix?: string + kind?: TelemetrySpanKind + /** + * Optional privacy policy and enrichment hook. The raw arguments are supplied + * only to consumer code and are never emitted automatically. + */ + attributes?: ( + method: keyof WalletInterface, + originator: OriginatorDomainNameStringUnder250Bytes | undefined + ) => Readonly> +} + +/** + * Wraps every BRC-100 method without changing its arguments or results. + * + * The first argument object acts as an explicit context carrier. This keeps + * concurrent browser and React Native calls correlated without global async + * state and lets downstream Wallet Toolbox layers attach child spans. + */ +export function instrumentWallet( + wallet: WalletInterface, + config: Telemetry | TelemetryConfig, + options: WalletInstrumentationOptions = {} +): WalletInterface { + const telemetry = config instanceof Telemetry ? config : new Telemetry(config) + if (!telemetry.enabled) return wallet + + const component = options.component ?? 'wallet' + const prefix = options.spanNamePrefix ?? 'wallet.call' + const kind = options.kind ?? 'internal' + const methods = new Map() + + return new Proxy(wallet, { + get(target, property, receiver) { + if (!walletMethodNames.has(property as keyof WalletInterface)) { + const value = Reflect.get(target, property, receiver) + return typeof value === 'function' ? value.bind(target) : value + } + + const cached = methods.get(property) + if (cached != null) return cached + + const original = Reflect.get(target, property, target) + if (typeof original !== 'function') return original + const method = property as keyof WalletInterface + const wrapped = ( + args: object, + originator?: OriginatorDomainNameStringUnder250Bytes + ): unknown => + telemetry.withSpan( + `${prefix}.${String(method)}`, + { + component, + kind, + carrier: args, + attributes: { + 'wallet.method': String(method), + ...options.attributes?.(method, originator) + } + }, + span => { + span.bind(args) + return original.call(target, args, originator) + } + ) + methods.set(property, wrapped) + return wrapped + } + }) +} diff --git a/packages/sdk/src/telemetry/__tests/Telemetry.test.ts b/packages/sdk/src/telemetry/__tests/Telemetry.test.ts deleted file mode 100644 index 5bc47bfbd..000000000 --- a/packages/sdk/src/telemetry/__tests/Telemetry.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Telemetry, TelemetryEvent } from '../Telemetry' - -describe('Telemetry', () => { - it('is disabled by default and isolates sink failures', async () => { - const disabledSink = jest.fn() - new Telemetry({ sink: { capture: disabledSink }, enabled: false }).capture({ - name: 'wallet.test', - component: 'wallet-toolbox' - }) - expect(disabledSink).not.toHaveBeenCalled() - - const throwing = new Telemetry({ - sink: { - capture: () => { - throw new Error('sink failed') - } - } - }) - expect(() => throwing.capture({ name: 'wallet.test', component: 'wallet-toolbox' })).not.toThrow() - - const rejecting = new Telemetry({ - sink: { capture: async () => await Promise.reject(new Error('sink failed')) } - }) - expect(() => rejecting.capture({ name: 'wallet.test', component: 'wallet-toolbox' })).not.toThrow() - await Promise.resolve() - }) - - it('redacts secret-bearing attributes and diagnostic text', () => { - let captured: TelemetryEvent | undefined - const telemetry = new Telemetry({ - sink: { capture: event => { captured = event } }, - includeErrorStack: true, - now: () => 123 - }) - const privateKey = '1'.repeat(64) - const snapshot = 'A'.repeat(256) - const serializedPrivateKey = `[${Array.from({ length: 32 }, (_, i) => i).join(',')}]` - - telemetry.capture({ - name: 'wallet.auth.failed', - component: 'wallet-toolbox', - correlationId: 'support-case-1', - attributes: { - password: 'do-not-report', - snapshot, - presentationKey: privateKey, - durationMs: 42, - diagnostic: serializedPrivateKey, - nestedPayload: { privateKey } - }, - error: new Error( - `password=do-not-report private=${privateKey} bytes=${serializedPrivateKey} blob=${snapshot}` - ) - }) - - expect(captured).toMatchObject({ - name: 'wallet.auth.failed', - component: 'wallet-toolbox', - timestamp: 123, - correlationId: 'support-case-1', - attributes: { - password: '[REDACTED]', - snapshot: '[REDACTED]', - presentationKey: '[REDACTED]', - durationMs: 42, - diagnostic: '[REDACTED]', - nestedPayload: '[REDACTED]' - } - }) - expect(JSON.stringify(captured)).not.toContain('do-not-report') - expect(JSON.stringify(captured)).not.toContain(privateKey) - expect(JSON.stringify(captured)).not.toContain(snapshot) - expect(JSON.stringify(captured)).not.toContain(serializedPrivateKey) - }) - - it('filters by severity and re-sanitizes beforeSend enrichment', () => { - const captured: TelemetryEvent[] = [] - const telemetry = new Telemetry({ - sink: { capture: event => { captured.push(event) } }, - minimumSeverity: 'warn', - beforeSend: event => ({ - ...event, - attributes: { - ...event.attributes, - recoveryKey: '2'.repeat(64), - supportTier: 'production' - } - }) - }) - - telemetry.capture({ name: 'debug', component: 'test', severity: 'debug' }) - telemetry.capture({ name: 'warning', component: 'test', severity: 'warn' }) - - expect(captured).toHaveLength(1) - expect(captured[0].attributes).toEqual({ - recoveryKey: '[REDACTED]', - supportTier: 'production' - }) - - let mutatedCapture: TelemetryEvent | undefined - const mutatingHook = new Telemetry({ - sink: { capture: event => { mutatedCapture = event } }, - beforeSend: event => { - const attributes = event.attributes as Record - attributes.snapshot = 'A'.repeat(256) - return undefined - } - }) - mutatingHook.capture({ - name: 'mutated', - component: 'test', - attributes: { supportTier: 'production' } - }) - expect(mutatedCapture?.attributes).toEqual({ - supportTier: 'production', - snapshot: '[REDACTED]' - }) - }) -}) diff --git a/packages/sdk/src/telemetry/__tests__/Telemetry.test.ts b/packages/sdk/src/telemetry/__tests__/Telemetry.test.ts new file mode 100644 index 000000000..04469c8ba --- /dev/null +++ b/packages/sdk/src/telemetry/__tests__/Telemetry.test.ts @@ -0,0 +1,333 @@ +import { Telemetry, TelemetryEvent, TelemetrySpanContext } from '../Telemetry' + +describe('Telemetry', () => { + it('is disabled by default and isolates sink failures', async () => { + const disabledSink = jest.fn() + new Telemetry({ sink: { capture: disabledSink }, enabled: false }).capture({ + name: 'wallet.test', + component: 'wallet-toolbox' + }) + expect(disabledSink).not.toHaveBeenCalled() + + const throwing = new Telemetry({ + sink: { + capture: () => { + throw new Error('sink failed') + } + } + }) + expect(() => + throwing.capture({ name: 'wallet.test', component: 'wallet-toolbox' }) + ).not.toThrow() + + const rejecting = new Telemetry({ + sink: { capture: async () => await Promise.reject(new Error('sink failed')) } + }) + expect(() => + rejecting.capture({ name: 'wallet.test', component: 'wallet-toolbox' }) + ).not.toThrow() + await Promise.resolve() + }) + + it('supports a fail-closed runtime enablement predicate', () => { + const events: TelemetryEvent[] = [] + let enabled = false + const telemetry = new Telemetry({ + enabled: () => enabled, + sink: { + capture: event => { + events.push(event) + } + } + }) + + expect(telemetry.enabled).toBe(false) + telemetry.capture({ name: 'disabled', component: 'test' }) + enabled = true + expect(telemetry.enabled).toBe(true) + telemetry.capture({ name: 'enabled', component: 'test' }) + expect(events.map(event => event.name)).toEqual(['enabled']) + + const failClosed = new Telemetry({ + enabled: () => { + throw new Error('preference unavailable') + }, + sink: { capture: jest.fn() } + }) + expect(failClosed.enabled).toBe(false) + }) + + it('redacts secret-bearing attributes and diagnostic text', () => { + let captured: TelemetryEvent | undefined + const telemetry = new Telemetry({ + sink: { + capture: event => { + captured = event + } + }, + includeErrorStack: true, + now: () => 123 + }) + const privateKey = '1'.repeat(64) + const snapshot = 'A'.repeat(256) + const serializedPrivateKey = `[${Array.from({ length: 32 }, (_, i) => i).join(',')}]` + + telemetry.capture({ + name: 'wallet.auth.failed', + component: 'wallet-toolbox', + correlationId: 'support-case-1', + attributes: { + password: 'do-not-report', + snapshot, + presentationKey: privateKey, + durationMs: 42, + diagnostic: serializedPrivateKey, + nestedPayload: { privateKey } + }, + error: new Error( + `password=do-not-report private=${privateKey} bytes=${serializedPrivateKey} blob=${snapshot}` + ) + }) + + expect(captured).toMatchObject({ + name: 'wallet.auth.failed', + component: 'wallet-toolbox', + timestamp: 123, + correlationId: 'support-case-1', + attributes: { + password: '[REDACTED]', + snapshot: '[REDACTED]', + presentationKey: '[REDACTED]', + durationMs: 42, + diagnostic: '[REDACTED]', + nestedPayload: '[REDACTED]' + } + }) + expect(JSON.stringify(captured)).not.toContain('do-not-report') + expect(JSON.stringify(captured)).not.toContain(privateKey) + expect(JSON.stringify(captured)).not.toContain(snapshot) + expect(JSON.stringify(captured)).not.toContain(serializedPrivateKey) + }) + + it('filters by severity and re-sanitizes beforeSend enrichment', () => { + const captured: TelemetryEvent[] = [] + const telemetry = new Telemetry({ + sink: { + capture: event => { + captured.push(event) + } + }, + minimumSeverity: 'warn', + beforeSend: event => ({ + ...event, + attributes: { + ...event.attributes, + recoveryKey: '2'.repeat(64), + supportTier: 'production' + } + }) + }) + + telemetry.capture({ name: 'debug', component: 'test', severity: 'debug' }) + telemetry.capture({ name: 'warning', component: 'test', severity: 'warn' }) + + expect(captured).toHaveLength(1) + expect(captured[0].attributes).toEqual({ + recoveryKey: '[REDACTED]', + supportTier: 'production' + }) + + let mutatedCapture: TelemetryEvent | undefined + const mutatingHook = new Telemetry({ + sink: { + capture: event => { + mutatedCapture = event + } + }, + beforeSend: event => { + const attributes = event.attributes as Record + attributes.snapshot = 'A'.repeat(256) + return undefined + } + }) + mutatingHook.capture({ + name: 'mutated', + component: 'test', + attributes: { supportTier: 'production' } + }) + expect(mutatedCapture?.attributes).toEqual({ + supportTier: 'production', + snapshot: '[REDACTED]' + }) + }) + + it('records monotonic spans, runtime deltas, and parentage', async () => { + const captured: TelemetryEvent[] = [] + const highResolutionTimes = [10, 12, 13, 18] + const ids = ['11111111111111111111111111111111', '2222222222222222', '3333333333333333'] + let runtimeCounter = 0 + const telemetry = new Telemetry({ + sink: { + capture: event => { + captured.push(event) + } + }, + now: () => 1_000, + highResolutionNow: () => highResolutionTimes.shift() ?? 18, + traceIdFactory: () => ids.shift()!, + spanIdFactory: () => ids.shift()!, + runtimeMetrics: { + snapshot: () => runtimeCounter++, + diff: (start, end) => ({ + 'runtime.cpu_ms': (end as number) - (start as number) + }) + } + }) + const carrier = {} + + await telemetry.withSpan( + 'wallet.call.createAction', + { component: 'wallet', kind: 'server', carrier }, + async root => { + expect(telemetry.contextFor(carrier)).toEqual(root.context) + await root.child('wallet.validate', { component: 'wallet' }).end({ + attributes: { 'validation.result': 'ok' } + }) + } + ) + + expect(captured).toHaveLength(2) + const child = captured[0] + const root = captured[1] + expect(root).toMatchObject({ + name: 'wallet.call.createAction', + type: 'span', + traceId: '11111111111111111111111111111111', + spanId: '2222222222222222', + spanKind: 'server', + spanStatus: 'ok', + startTimestamp: 1_000, + durationMs: 8, + attributes: { 'runtime.cpu_ms': 3 } + }) + expect(child).toMatchObject({ + name: 'wallet.validate', + traceId: root.traceId, + spanId: '3333333333333333', + parentSpanId: root.spanId, + durationMs: 1, + attributes: { + 'runtime.cpu_ms': 1, + 'validation.result': 'ok' + } + }) + }) + + it('keeps explicit carriers isolated across parallel calls', async () => { + const events: TelemetryEvent[] = [] + const telemetry = new Telemetry({ + sink: { + capture: event => { + events.push(event) + } + } + }) + const firstCarrier = {} + const secondCarrier = {} + const first = telemetry.startSpan('first', { component: 'test', carrier: firstCarrier }) + const second = telemetry.startSpan('second', { component: 'test', carrier: secondCarrier }) + + const firstChild = telemetry.startSpan('first.child', { + component: 'test', + carrier: firstCarrier + }) + const secondChild = telemetry.startSpan('second.child', { + component: 'test', + carrier: secondCarrier + }) + firstChild.end() + secondChild.end() + first.end() + second.end() + + expect(first.context.traceId).not.toBe(second.context.traceId) + expect(events.find(event => event.name === 'first.child')).toMatchObject({ + traceId: first.context.traceId, + parentSpanId: first.context.spanId + }) + expect(events.find(event => event.name === 'second.child')).toMatchObject({ + traceId: second.context.traceId, + parentSpanId: second.context.spanId + }) + }) + + it('uses a host context manager for asynchronous child work', async () => { + let active: TelemetrySpanContext | undefined + const events: TelemetryEvent[] = [] + const telemetry = new Telemetry({ + sink: { + capture: event => { + events.push(event) + } + }, + contextManager: { + active: () => active, + run: (context, callback) => { + const previous = active + active = context + try { + return callback() + } finally { + active = previous + } + } + } + }) + + telemetry.withSpan('root', { component: 'test' }, root => { + const child = telemetry.startSpan('child', { component: 'test' }) + expect(child.parentSpanId).toBe(root.context.spanId) + child.end() + }) + + expect(events.map(event => event.name)).toEqual(['child', 'root']) + }) + + it('records rejected promises once and never lets an error hook replace the error', async () => { + const captured: TelemetryEvent[] = [] + const telemetry = new Telemetry({ + sink: { + capture: event => { + captured.push(event) + } + }, + beforeSend: event => ({ + ...event, + traceId: 'not-a-trace', + durationMs: Number.NaN + }) + }) + + await expect( + telemetry.withSpan( + 'failed', + { component: 'test' }, + async () => await Promise.reject(new Error('expected failure')) + ) + ).rejects.toThrow('expected failure') + + expect(captured).toHaveLength(1) + expect(captured[0]).toMatchObject({ + name: 'failed', + type: 'span', + spanStatus: 'error', + severity: 'error', + error: { + name: 'Error', + message: 'expected failure' + } + }) + expect(captured[0].traceId).toBeUndefined() + expect(captured[0].durationMs).toBeUndefined() + }) +}) diff --git a/packages/sdk/src/telemetry/__tests__/TraceContext.test.ts b/packages/sdk/src/telemetry/__tests__/TraceContext.test.ts new file mode 100644 index 000000000..f7b0208cf --- /dev/null +++ b/packages/sdk/src/telemetry/__tests__/TraceContext.test.ts @@ -0,0 +1,30 @@ +import { formatTraceparent, parseTraceparent } from '../TraceContext' + +describe('W3C trace context', () => { + const traceId = '11111111111111111111111111111111' + const spanId = '2222222222222222' + + it('round-trips a valid version-00 traceparent', () => { + const value = formatTraceparent({ traceId, spanId, traceFlags: 1 }) + expect(value).toBe(`00-${traceId}-${spanId}-01`) + expect(parseTraceparent(value)).toEqual({ traceId, spanId, traceFlags: 1 }) + }) + + it.each([ + undefined, + '', + `01-${traceId}-${spanId}-01`, + `00-${'0'.repeat(32)}-${spanId}-01`, + `00-${traceId}-${'0'.repeat(16)}-01`, + `00-${traceId}-short-01`, + `00-${traceId}-${spanId}-0100`, + 'x'.repeat(129) + ])('rejects malformed or unsupported input %#', value => { + expect(parseTraceparent(value)).toBeUndefined() + }) + + it('does not format invalid identifiers', () => { + expect(formatTraceparent({ traceId: 'invalid', spanId })).toBeUndefined() + expect(formatTraceparent({ traceId, spanId: 'invalid' })).toBeUndefined() + }) +}) diff --git a/packages/sdk/src/telemetry/__tests__/WalletInstrumentation.test.ts b/packages/sdk/src/telemetry/__tests__/WalletInstrumentation.test.ts new file mode 100644 index 000000000..96cc5bbdd --- /dev/null +++ b/packages/sdk/src/telemetry/__tests__/WalletInstrumentation.test.ts @@ -0,0 +1,68 @@ +import type { WalletInterface } from '../../wallet/Wallet.interfaces' +import { Telemetry, type TelemetryEvent } from '../Telemetry' +import { instrumentWallet } from '../WalletInstrumentation' + +describe('instrumentWallet', () => { + it('wraps BRC-100 calls and carries context into their argument object', async () => { + const events: TelemetryEvent[] = [] + let telemetry: Telemetry + const getVersion = jest.fn(async (args: object) => { + const child = telemetry.startSpan('wallet.inner', { + component: 'wallet', + carrier: args + }) + child.end() + return { version: '1.0.0' } + }) + const wallet = { getVersion } as unknown as WalletInterface + telemetry = new Telemetry({ + sink: { + capture: event => { + events.push(event) + } + } + }) + + const instrumented = instrumentWallet(wallet, telemetry, { + component: 'bridge', + kind: 'server', + attributes: method => ({ 'bridge.method': String(method) }) + }) + await instrumented.getVersion({}) + + expect(getVersion).toHaveBeenCalledTimes(1) + expect(events.map(event => event.name)).toEqual(['wallet.inner', 'wallet.call.getVersion']) + expect(events[0]).toMatchObject({ + traceId: events[1].traceId, + parentSpanId: events[1].spanId + }) + expect(events[1]).toMatchObject({ + component: 'bridge', + spanKind: 'server', + attributes: { + 'wallet.method': 'getVersion', + 'bridge.method': 'getVersion' + } + }) + }) + + it('returns the original wallet when telemetry is disabled', () => { + const wallet = {} as WalletInterface + expect(instrumentWallet(wallet, {})).toBe(wallet) + }) + + it('preserves non-wallet members and method binding', () => { + const wallet = { + value: 7, + customMethod() { + return this.value + } + } as unknown as WalletInterface + const instrumented = instrumentWallet(wallet, { + sink: { capture: () => {} } + }) as WalletInterface & { value: number; customMethod: () => number } + + expect(instrumented.value).toBe(7) + expect(instrumented.customMethod()).toBe(7) + }) +}) diff --git a/packages/sdk/src/telemetry/index.ts b/packages/sdk/src/telemetry/index.ts index 8a43c5d67..277d4938b 100644 --- a/packages/sdk/src/telemetry/index.ts +++ b/packages/sdk/src/telemetry/index.ts @@ -1 +1,3 @@ export * from './Telemetry.js' +export * from './TraceContext.js' +export * from './WalletInstrumentation.js' diff --git a/packages/wallet/wallet-toolbox/src/Wallet.ts b/packages/wallet/wallet-toolbox/src/Wallet.ts index bcf3f61d1..35047233d 100644 --- a/packages/wallet/wallet-toolbox/src/Wallet.ts +++ b/packages/wallet/wallet-toolbox/src/Wallet.ts @@ -68,7 +68,9 @@ import { KeyDeriverApi, Validation, WalletLoggerInterface, - MakeWalletLogger + MakeWalletLogger, + Telemetry, + TelemetryConfig } from '@bsv/sdk' import type { SpendVerifierInterface } from '@bsv/sdk' import { acquireDirectCertificate } from './signer/methods/acquireDirectCertificate' @@ -213,6 +215,11 @@ export interface WalletArgs { * not change the BRC-100 wallet interface. */ scriptVerifier?: SpendVerifierInterface + /** + * Optional provider-neutral tracing shared by wallet, lookup, storage, and + * permission layers. Disabled unless an enabled sink is supplied. + */ + telemetry?: TelemetryConfig } function isWalletSigner(args: WalletArgs | WalletSigner): args is WalletSigner { @@ -266,6 +273,7 @@ export class Wallet implements WalletInterface, ProtoWallet { pendingSignActions: Record readonly actionBatch: ActionBatchController readonly scriptVerifier?: SpendVerifierInterface + readonly telemetry: Telemetry /** * For repeatability testing, set to an array of random numbers from [0..1). @@ -298,12 +306,14 @@ export class Wallet implements WalletInterface, ProtoWallet { ) } + this.telemetry = new Telemetry(args.telemetry) this.settingsManager = args.settingsManager || new WalletSettingsManager(this) this.chain = args.chain this.lookupResolver = args.lookupResolver || new LookupResolver({ - networkPreset: toLookupNetworkPreset(this.chain) + networkPreset: toLookupNetworkPreset(this.chain), + telemetry: args.telemetry }) this.keyDeriver = args.keyDeriver this.storage = args.storage @@ -498,9 +508,32 @@ export class Wallet implements WalletInterface, ProtoWallet { validate: (args: A, logger?: WalletLoggerInterface) => T, logger?: WalletLoggerInterface ): { vargs: T; auth: AuthId } { - const vargs = validate(args, logger) - const auth: AuthId = { identityKey: this.identityKey } - return { vargs, auth } + if (!this.telemetry.enabled || typeof args !== 'object' || args == null) { + const vargs = validate(args, logger) + const auth: AuthId = { identityKey: this.identityKey } + return { vargs, auth } + } + + const span = this.telemetry.startSpan('wallet.validate_args', { + component: 'wallet-toolbox', + carrier: args + }) + try { + const vargs = validate(args, logger) + if (typeof vargs === 'object' && vargs != null) { + this.telemetry.bindContext(vargs, span.context) + } + const auth: AuthId = { identityKey: this.identityKey } + span.end({ attributes: { 'validation.result': 'ok' } }) + return { vargs, auth } + } catch (error) { + span.end({ + status: 'error', + error, + attributes: { 'validation.result': 'rejected' } + }) + throw error + } } /// /////////////// diff --git a/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts b/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts index beeca94a6..dc8de3725 100644 --- a/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts +++ b/packages/wallet/wallet-toolbox/src/WalletPermissionsManager.ts @@ -22,7 +22,9 @@ import { CreateActionArgs, ListOutputsResult, ListActionsArgs, - ListActionsResult + ListActionsResult, + Telemetry, + TelemetryConfig } from '@bsv/sdk' import { parseBrc114ActionTimeLabels } from './utility/brc114ActionTimeLabels' @@ -308,6 +310,13 @@ export interface WalletPermissionsManagerCallbacks { * By default, all of these are `true` unless specified otherwise. This is the most secure configuration. */ export interface PermissionsManagerConfig { + /** + * Optional provider-neutral permission timing. Originators, permission-token + * contents, manifests, descriptions, labels, and transaction data are never + * emitted by the generic instrumentation. + */ + telemetry?: TelemetryConfig + /** * A map of P-basket/protocol permission scheme modules. * @@ -573,6 +582,7 @@ export class WalletPermissionsManager implements WalletInterface { * Configuration that determines whether to skip or apply various checks and encryption. */ private readonly config: PermissionsManagerConfig + private readonly telemetry: Telemetry /** * Constructs a new Permissions Manager instance. @@ -584,6 +594,7 @@ export class WalletPermissionsManager implements WalletInterface { constructor(underlyingWallet: WalletInterface, adminOriginator: string, config: PermissionsManagerConfig = {}) { this.underlying = underlyingWallet this.adminOriginator = this.normalizeOriginator(adminOriginator) || adminOriginator + this.telemetry = new Telemetry(config.telemetry) // Default all config options to true unless specified this.config = { @@ -849,10 +860,25 @@ export class WalletPermissionsManager implements WalletInterface { */ private async callEvent(eventName: keyof WalletPermissionsManagerCallbacks, param: any): Promise { const arr = this.callbacks[eventName] || [] - for (const cb of arr) { + for (const [callbackIndex, cb] of arr.entries()) { if (typeof cb === 'function') { try { - await cb(param) + if (this.telemetry.enabled && typeof param === 'object' && param != null) { + await this.telemetry.withSpan( + 'wallet.permission.callback', + { + component: 'wallet-permissions', + carrier: param, + attributes: { + 'permission.callback': eventName, + 'permission.callback_index': callbackIndex + } + }, + async () => await cb(param) + ) + } else { + await cb(param) + } } catch { // Intentionally swallow errors from user-provided callbacks to prevent // a misbehaving callback from disrupting the event dispatch loop. @@ -2250,12 +2276,41 @@ export class WalletPermissionsManager implements WalletInterface { * and return a promise that resolves once permission is granted or rejects if denied. */ private async requestPermissionFlow(r: PermissionRequest): Promise { + if (!this.telemetry.enabled) return await this.requestPermissionFlowCore(r) + return await this.telemetry.withSpan( + 'wallet.permission.request', + { + component: 'wallet-permissions', + carrier: r, + attributes: { + 'permission.type': r.type, + 'permission.renewal': r.renewal === true + } + }, + async span => { + const result = await this.requestPermissionFlowCore(r, span.context) + span.end({ + attributes: { + 'permission.granted': result + } + }) + return result + } + ) + } + + private async requestPermissionFlowCore( + r: PermissionRequest, + context?: { traceId: string; spanId: string; traceFlags?: number } + ): Promise { + if (context != null) this.telemetry.bindContext(r, context) const normalizedOriginator = this.normalizeOriginator(r.originator) || r.originator const preparedRequest: PermissionRequest = { ...r, originator: normalizedOriginator, displayOriginator: r.displayOriginator ?? r.previousToken?.rawOriginator ?? r.originator } + if (context != null) this.telemetry.bindContext(preparedRequest, context) if (preparedRequest.type === 'protocol' && preparedRequest.protocolID?.[0] === 1) { preparedRequest.counterparty = '' @@ -2344,24 +2399,33 @@ export class WalletPermissionsManager implements WalletInterface { private async firePermissionRequestEvent(request: PermissionRequest, key: string): Promise { switch (request.type) { case 'protocol': - await this.callEvent('onProtocolPermissionRequested', { + await this.callPermissionEvent(request, 'onProtocolPermissionRequested', { ...request, counterparty: request.protocolID?.[0] === 1 ? undefined : request.counterparty, requestID: key }) break case 'basket': - await this.callEvent('onBasketAccessRequested', { ...request, requestID: key }) + await this.callPermissionEvent(request, 'onBasketAccessRequested', { ...request, requestID: key }) break case 'certificate': - await this.callEvent('onCertificateAccessRequested', { ...request, requestID: key }) + await this.callPermissionEvent(request, 'onCertificateAccessRequested', { ...request, requestID: key }) break case 'spending': - await this.callEvent('onSpendingAuthorizationRequested', { ...request, requestID: key }) + await this.callPermissionEvent(request, 'onSpendingAuthorizationRequested', { ...request, requestID: key }) break } } + private async callPermissionEvent( + request: PermissionRequest, + eventName: keyof WalletPermissionsManagerCallbacks, + param: object + ): Promise { + this.telemetry.linkContext(request, param) + await this.callEvent(eventName, param) + } + /* --------------------------------------------------------------------- * 4) SEARCH / DECODE / DECRYPT ON-CHAIN TOKENS (PushDrop Scripts) * --------------------------------------------------------------------- */ @@ -4993,6 +5057,26 @@ export class WalletPermissionsManager implements WalletInterface { * user a duplicate prompt for a grant they already made moments ago. */ private async hasRecentOrPendingGrant(cacheKey: string): Promise { + if (!this.telemetry.enabled) return await this.hasRecentOrPendingGrantCore(cacheKey) + return await this.telemetry.withSpan( + 'wallet.permission.cache_check', + { + component: 'wallet-permissions' + }, + async span => { + const result = await this.hasRecentOrPendingGrantCore(cacheKey) + span.end({ + attributes: { + 'permission.cache_hit': result, + 'permission.mint_in_flight': this.mintsInFlight.has(cacheKey) + } + }) + return result + } + ) + } + + private async hasRecentOrPendingGrantCore(cacheKey: string): Promise { if (this.isPermissionCached(cacheKey) || this.isRecentlyGranted(cacheKey)) return true const inFlight = this.mintsInFlight.get(cacheKey) if (inFlight == null) return false diff --git a/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.callbacks.test.ts b/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.callbacks.test.ts index f7ac6cd0b..d083e4cd0 100644 --- a/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.callbacks.test.ts +++ b/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.callbacks.test.ts @@ -1,6 +1,6 @@ import { mockUnderlyingWallet, MockedBSV_SDK } from './WalletPermissionsManager.fixtures' import { WalletPermissionsManager, GroupedPermissions } from '../WalletPermissionsManager' -import { WalletProtocol } from '@bsv/sdk' +import { TelemetryEvent, WalletProtocol } from '@bsv/sdk' import { jest } from '@jest/globals' @@ -193,6 +193,59 @@ describe('WalletPermissionsManager - Callbacks & Event Handling', () => { await expect(signPromise).rejects.toThrow(/Permission denied/) }) + it('correlates permission callbacks without reporting request details', async () => { + const events: TelemetryEvent[] = [] + manager = new WalletPermissionsManager(underlying, 'admin.domain.com', { + telemetry: { + sink: { + capture: event => { + events.push(event) + } + } + } + }) + + let requestID = '' + manager.bindCallback('onProtocolPermissionRequested', param => { + requestID = param.requestID + }) + + const signPromise = manager.createSignature( + { + protocolID: [1, 'private-protocol-name'], + keyID: 'private-key-id', + data: [0x01, 0x02], + privileged: false + }, + 'private-origin.example.com' + ) + await new Promise(resolve => setTimeout(resolve, 10)) + await manager.denyPermission(requestID) + await expect(signPromise).rejects.toThrow(/Permission denied/) + + const requestSpan = events.find(event => event.name === 'wallet.permission.request') + const callbackSpan = events.find(event => event.name === 'wallet.permission.callback') + expect(requestSpan).toMatchObject({ + spanStatus: 'error', + attributes: { + 'permission.type': 'protocol', + 'permission.renewal': false + } + }) + expect(callbackSpan).toMatchObject({ + traceId: requestSpan?.traceId, + parentSpanId: requestSpan?.spanId, + attributes: { + 'permission.callback': 'onProtocolPermissionRequested', + 'permission.callback_index': 0 + } + }) + expect(JSON.stringify(events)).not.toContain('private-origin.example.com') + expect(JSON.stringify(events)).not.toContain('private-protocol-name') + expect(JSON.stringify(events)).not.toContain('private-key-id') + expect(JSON.stringify(events)).not.toContain(requestID) + }) + it('should resolve the original caller promise when requests are granted', async () => { const requestedCb = jest.fn(() => {}) manager.bindCallback('onProtocolPermissionRequested', requestedCb) diff --git a/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.fixtures.ts b/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.fixtures.ts index 2898cc9e8..695801739 100644 --- a/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.fixtures.ts +++ b/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.fixtures.ts @@ -1,4 +1,4 @@ -const { Validation } = jest.requireActual('@bsv/sdk') +const { Telemetry, Validation } = jest.requireActual('@bsv/sdk') const existingFetch = (globalThis as any).fetch if (existingFetch?._isMockFunction == null) { @@ -219,6 +219,7 @@ export const MockedBsvSdk = { Utils: MockUtils, Random: MockRandom, Certificate: null, + Telemetry, Validation } diff --git a/packages/wallet/wallet-toolbox/src/sdk/WalletServices.interfaces.ts b/packages/wallet/wallet-toolbox/src/sdk/WalletServices.interfaces.ts index b91beac15..a143ae6ed 100644 --- a/packages/wallet/wallet-toolbox/src/sdk/WalletServices.interfaces.ts +++ b/packages/wallet/wallet-toolbox/src/sdk/WalletServices.interfaces.ts @@ -3,6 +3,7 @@ import { Transaction as BsvTransaction, ChainTracker, MerklePath, + TelemetryConfig, WalletLoggerInterface } from '@bsv/sdk' // ArcConfig is sourced from the local ARC provider (a superset of the @bsv/sdk ArcConfig @@ -207,27 +208,15 @@ export interface FiatExchangeRates { } export type FiatCurrencyCode = - | 'USD' - | 'EUR' - | 'GBP' - | 'JPY' - | 'CNY' - | 'INR' - | 'AUD' - | 'CAD' - | 'CHF' - | 'HKD' - | 'SGD' - | 'NZD' - | 'SEK' - | 'NOK' - | 'MXN' + 'USD' | 'EUR' | 'GBP' | 'JPY' | 'CNY' | 'INR' | 'AUD' | 'CAD' | 'CHF' | 'HKD' | 'SGD' | 'NZD' | 'SEK' | 'NOK' | 'MXN' export interface WalletServicesOptions { /** * 'main' or 'test': which BSV chain to use */ chain: Chain + /** Optional provider-neutral service and ChainTracks tracing. */ + telemetry?: TelemetryConfig /** * As of 2025-08-31 the `taalApiKey` is unused for default configured services. * See `arcConfig` instead. @@ -679,7 +668,7 @@ export interface ServiceCall { /** * Error code and message iff success is false and a exception was thrown. */ - error?: { message: string, code: string } + error?: { message: string; code: string } } /** diff --git a/packages/wallet/wallet-toolbox/src/services/Services.ts b/packages/wallet/wallet-toolbox/src/services/Services.ts index d3cad9674..aa54087e1 100644 --- a/packages/wallet/wallet-toolbox/src/services/Services.ts +++ b/packages/wallet/wallet-toolbox/src/services/Services.ts @@ -1,4 +1,12 @@ -import { Transaction as BsvTransaction, Beef, ChainTracker, Utils, WalletLoggerInterface } from '@bsv/sdk' +import { + Transaction as BsvTransaction, + Beef, + ChainTracker, + Telemetry, + TelemetrySpan, + Utils, + WalletLoggerInterface +} from '@bsv/sdk' import { ServiceCollection, ServiceToCall } from './ServiceCollection' import { createDefaultWalletServicesOptions } from './createDefaultWalletServicesOptions' import { WhatsOnChain } from './providers/WhatsOnChain' @@ -62,6 +70,7 @@ export class Services implements WalletServices { updateFiatExchangeRateServices!: ServiceCollection chain: Chain + readonly telemetry: Telemetry constructor(optionsOrChain: Chain | WalletServicesOptions) { this.chain = typeof optionsOrChain === 'string' ? optionsOrChain : optionsOrChain.chain @@ -71,6 +80,7 @@ export class Services implements WalletServices { } this.options = typeof optionsOrChain === 'string' ? Services.createDefaultOptions(this.chain) : optionsOrChain + this.telemetry = new Telemetry(this.options.telemetry) this.whatsonchain = new WhatsOnChain(this.chain, { apiKey: this.options.whatsOnChainApiKey }, this) @@ -205,7 +215,9 @@ export class Services implements WalletServices { if (this.options.chaintracks == null) { throw new WERR_INVALID_PARAMETER('options.chaintracks', "valid to enable 'getChainTracker' service.") } - return new ChaintracksChainTracker(this.chain, this.options.chaintracks) + return new ChaintracksChainTracker(this.chain, this.options.chaintracks, { + telemetry: this.options.telemetry + }) } async getBsvExchangeRate(): Promise { @@ -592,13 +604,42 @@ export class Services implements WalletServices { return false } - async invokeChaintracksWithRetry(method: () => Promise): Promise { + async invokeChaintracksWithRetry(method: () => Promise, operation: string = 'unknown'): Promise { if (this.options.chaintracks == null) { throw new WERR_INVALID_PARAMETER('options.chaintracks', 'valid for this service operation.') } + if (!this.telemetry.enabled) return await this.invokeChaintracksWithRetryCore(method) + return await this.telemetry.withSpan( + 'wallet.chaintracks.request', + { + component: 'wallet-services', + kind: 'client', + attributes: { + 'chaintracks.operation': operation + } + }, + async span => await this.invokeChaintracksWithRetryCore(method, span) + ) + } + + private async invokeChaintracksWithRetryCore(method: () => Promise, parent?: TelemetrySpan): Promise { for (let retry = 0; retry < 3; retry++) { try { - const r: R = await method() + const r: R = + parent == null + ? await method() + : await this.telemetry.withSpan( + 'wallet.chaintracks.attempt', + { + component: 'wallet-services', + kind: 'client', + parent: parent.context, + attributes: { + 'retry.attempt': retry + 1 + } + }, + method + ) return r } catch (error_: unknown) { const e = WalletError.fromUnknown(error_) @@ -616,7 +657,7 @@ export class Services implements WalletServices { throw new WERR_INVALID_PARAMETER('hash', `valid height '${height}' on mined chain ${this.chain}`) return toBinaryBaseBlockHeader(header) } - return await this.invokeChaintracksWithRetry(method) + return await this.invokeChaintracksWithRetry(method, 'find_header_for_height') } async getHeight(): Promise { @@ -624,7 +665,7 @@ export class Services implements WalletServices { const chaintracks = this.options.chaintracks as NonNullable return await chaintracks.currentHeight() } - return await this.invokeChaintracksWithRetry(method) + return await this.invokeChaintracksWithRetry(method, 'current_height') } async hashToHeader(hash: string): Promise { @@ -633,7 +674,7 @@ export class Services implements WalletServices { const header = await chaintracks.findHeaderForBlockHash(hash) return header } - let header = await this.invokeChaintracksWithRetry(method) + let header = await this.invokeChaintracksWithRetry(method, 'find_header_for_hash') header ??= await this.whatsonchain.getBlockHeaderByHash(hash) if (header == null) throw new WERR_INVALID_PARAMETER('hash', `valid blockhash '${hash}' on mined chain ${this.chain}`) diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/ChaintracksChainTracker.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/ChaintracksChainTracker.ts index 1ea727549..d953c7d06 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/ChaintracksChainTracker.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/ChaintracksChainTracker.ts @@ -1,4 +1,4 @@ -import { ChainTracker } from '@bsv/sdk' +import { ChainTracker, Telemetry, TelemetryConfig, TelemetrySpan } from '@bsv/sdk' import { ChaintracksServiceClient } from './chaintracks/ChaintracksServiceClient' import { Chain } from '../../sdk/types' import { WalletError } from '../../sdk/WalletError' @@ -9,28 +9,57 @@ import { ChaintracksClientApi } from './chaintracks/Api/ChaintracksClientApi' export interface ChaintracksChainTrackerOptions { maxRetries?: number retryDelayMs?: number + telemetry?: TelemetryConfig } export class ChaintracksChainTracker implements ChainTracker { chaintracks: ChaintracksClientApi cache: Record options: ChaintracksChainTrackerOptions + readonly telemetry: Telemetry - constructor (chain?: Chain, chaintracks?: ChaintracksClientApi, options?: ChaintracksChainTrackerOptions) { + constructor(chain?: Chain, chaintracks?: ChaintracksClientApi, options?: ChaintracksChainTrackerOptions) { chain ||= 'main' this.chaintracks = chaintracks ?? new ChaintracksServiceClient(chain, `https://${chain}net-chaintracks.babbage.systems`) this.cache = {} this.options = options || {} + this.telemetry = new Telemetry(this.options.telemetry) } - async currentHeight (): Promise { - return await this.chaintracks.getPresentHeight() + async currentHeight(): Promise { + if (!this.telemetry.enabled) return await this.chaintracks.getPresentHeight() + return await this.telemetry.withSpan( + 'wallet.chaintracks.current_height', + { + component: 'chaintracks-chain-tracker', + kind: 'client' + }, + async () => await this.chaintracks.getPresentHeight() + ) } - async isValidRootForHeight (root: string, height: number): Promise { + async isValidRootForHeight(root: string, height: number): Promise { + if (!this.telemetry.enabled) return await this.isValidRootForHeightCore(root, height) + return await this.telemetry.withSpan( + 'wallet.chaintracks.validate_root', + { + component: 'chaintracks-chain-tracker', + kind: 'client' + }, + async span => await this.isValidRootForHeightCore(root, height, span) + ) + } + + private async isValidRootForHeightCore(root: string, height: number, parent?: TelemetrySpan): Promise { const cachedRoot = this.cache[height] if (cachedRoot) { + parent?.end({ + attributes: { + 'chaintracks.cache_hit': true, + 'chaintracks.valid': cachedRoot === root + } + }) return cachedRoot === root } @@ -43,7 +72,21 @@ export class ChaintracksChainTracker implements ChainTracker { for (let tryCount = 1; tryCount <= retries; tryCount++) { try { - header = await this.chaintracks.findHeaderForHeight(height) + header = + parent == null + ? await this.chaintracks.findHeaderForHeight(height) + : await this.telemetry.withSpan( + 'wallet.chaintracks.find_header', + { + component: 'chaintracks-chain-tracker', + kind: 'client', + parent: parent.context, + attributes: { + 'retry.attempt': tryCount + } + }, + async () => await this.chaintracks.findHeaderForHeight(height) + ) if (header == null) { if (tryCount >= retries) return false @@ -65,10 +108,13 @@ export class ChaintracksChainTracker implements ChainTracker { this.cache[height] = header.merkleRoot - if (header.merkleRoot !== root) { - return false - } - - return true + const valid = header.merkleRoot === root + parent?.end({ + attributes: { + 'chaintracks.cache_hit': false, + 'chaintracks.valid': valid + } + }) + return valid } } diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts index 817805c23..6fef5a145 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts @@ -72,6 +72,46 @@ describe('ChaintracksChaintracker tests', () => { await expect(tracker.isValidRootForHeight(HEADER_877599.merkleRoot, HEADER_877599.height)).rejects.toThrow('final failure') expect(chaintracks.findHeaderForHeight).toHaveBeenCalledTimes(2) }) + + test('traces retry attempts and cache disposition without roots or headers', async () => { + const events: any[] = [] + let nextSpanId = 1 + const chaintracks = makeChaintracksClient([undefined, HEADER_877599]) + const tracker = new ChaintracksChainTracker('main', chaintracks, { + maxRetries: 2, + retryDelayMs: 0, + telemetry: { + sink: { + capture: event => events.push(event) + }, + traceIdFactory: () => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanIdFactory: () => (nextSpanId++).toString(16).padStart(16, '0') + } + }) + + await expect( + tracker.isValidRootForHeight(HEADER_877599.merkleRoot, HEADER_877599.height) + ).resolves.toBe(true) + await expect( + tracker.isValidRootForHeight(HEADER_877599.merkleRoot, HEADER_877599.height) + ).resolves.toBe(true) + + const attempts = events.filter(event => event.name === 'wallet.chaintracks.find_header') + const validations = events.filter(event => event.name === 'wallet.chaintracks.validate_root') + expect(attempts).toHaveLength(2) + expect(attempts.map(event => event.attributes['retry.attempt'])).toEqual([1, 2]) + expect(validations).toHaveLength(2) + expect(validations[0].attributes).toMatchObject({ + 'chaintracks.cache_hit': false, + 'chaintracks.valid': true + }) + expect(validations[1].attributes).toMatchObject({ + 'chaintracks.cache_hit': true, + 'chaintracks.valid': true + }) + expect(JSON.stringify(events)).not.toContain(HEADER_877599.merkleRoot) + expect(JSON.stringify(events)).not.toContain(HEADER_877599.previousHash) + }) }) async function testChaintracksChaintracker (chain: sdk.Chain) { diff --git a/packages/wallet/wallet-toolbox/src/signer/methods/completeSignedTransaction.ts b/packages/wallet/wallet-toolbox/src/signer/methods/completeSignedTransaction.ts index 0cee31a95..8ac8aaca1 100644 --- a/packages/wallet/wallet-toolbox/src/signer/methods/completeSignedTransaction.ts +++ b/packages/wallet/wallet-toolbox/src/signer/methods/completeSignedTransaction.ts @@ -1,13 +1,10 @@ -import { - SignActionSpend, - Transaction -} from '@bsv/sdk' +import { SignActionSpend, Transaction } from '@bsv/sdk' import { PendingSignAction, Wallet } from '../../Wallet' import { WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' import { asBsvSdkScript } from '../../utility/utilityHelpers' import { ScriptTemplateBRC29 } from '../../utility/ScriptTemplateBRC29' -export async function completeSignedTransaction ( +export async function completeSignedTransaction( prior: PendingSignAction, spends: Record, wallet: Wallet @@ -57,7 +54,21 @@ export async function completeSignedTransaction ( /// ////////////////// // Sign wallet signed inputs making transaction fully valid. /// ////////////////// - await prior.tx.sign() + if (wallet.telemetry.enabled) { + await wallet.telemetry.withSpan( + 'wallet.crypto.transaction_sign', + { + component: 'wallet-toolbox', + carrier: prior.args, + attributes: { + 'crypto.input_count': prior.tx.inputs.length + } + }, + async () => await prior.tx.sign() + ) + } else { + await prior.tx.sign() + } return prior.tx } diff --git a/packages/wallet/wallet-toolbox/src/signer/methods/createAction.ts b/packages/wallet/wallet-toolbox/src/signer/methods/createAction.ts index 1d657f048..463a53c95 100644 --- a/packages/wallet/wallet-toolbox/src/signer/methods/createAction.ts +++ b/packages/wallet/wallet-toolbox/src/signer/methods/createAction.ts @@ -5,6 +5,7 @@ import { OutpointString, SendWithResult, SignableTransaction, + TelemetrySpan, TXIDHexString, Transaction, Validation @@ -34,6 +35,39 @@ export async function createAction( wallet: Wallet, auth: AuthId, vargs: Validation.ValidCreateActionArgs +): Promise { + if (!wallet.telemetry.enabled) return await createActionCore(wallet, auth, vargs) + return await wallet.telemetry.withSpan( + 'wallet.create_action', + { + component: 'wallet-toolbox', + carrier: vargs, + attributes: { + 'action.input_count': vargs.inputs.length, + 'action.output_count': vargs.outputs.length, + 'action.is_new_transaction': vargs.isNewTx, + 'action.is_sign_action': vargs.isSignAction + } + }, + async span => { + const result = await createActionCore(wallet, auth, vargs, span) + span.end({ + attributes: { + 'action.has_transaction': result.tx != null, + 'action.has_signable_transaction': result.signableTransaction != null, + 'action.send_result_count': result.sendWithResults?.length ?? 0 + } + }) + return result + } + ) +} + +async function createActionCore( + wallet: Wallet, + auth: AuthId, + vargs: Validation.ValidCreateActionArgs, + parent?: TelemetrySpan ): Promise { const r: CreateActionResultX = {} const logger = vargs.logger @@ -41,7 +75,7 @@ export async function createAction( let prior: PendingSignAction | undefined if (vargs.isNewTx || vargs.isTestWerrReviewActions) { - prior = await createNewTx(wallet, vargs) + prior = await createNewTx(wallet, vargs, parent) logger?.log('created new transaction') if (vargs.isSignAction) { @@ -50,7 +84,12 @@ export async function createAction( return r } - prior.tx = await completeSignedTransaction(prior, {}, wallet) + prior.tx = await traceActionStep( + wallet, + 'wallet.create_action.complete_signing', + parent, + async () => await completeSignedTransaction(prior!, {}, wallet) + ) logger?.log('completed signed transaction') r.txid = prior.tx.id('hex') @@ -65,7 +104,12 @@ export async function createAction( beef.mergeTransaction(prior.tx) logger?.log('merged beef') - await verifyUnlockScripts(r.txid, beef, wallet.scriptVerifier) + await traceActionStep( + wallet, + 'wallet.create_action.verify_unlock_scripts', + parent, + async () => await verifyUnlockScripts(r.txid!, beef, wallet.scriptVerifier) + ) logger?.log('verified unlock scripts') r.noSendChange = prior.dcr.noSendChangeOutputVouts?.map(vout => `${r.txid}.${vout}`) @@ -74,7 +118,12 @@ export async function createAction( if (!vargs.options.returnTXIDOnly) r.tx = beef.toUint8ArrayAtomic(r.txid) } - const { sendWithResults, notDelayedResults } = await processAction(prior, wallet, auth, vargs) + const { sendWithResults, notDelayedResults } = await traceActionStep( + wallet, + 'wallet.create_action.process', + parent, + async () => await processAction(prior, wallet, auth, vargs) + ) logger?.log('processed transaction') r.sendWithResults = sendWithResults @@ -83,14 +132,45 @@ export async function createAction( return r } -async function createNewTx(wallet: Wallet, vargs: Validation.ValidCreateActionArgs): Promise { +async function traceActionStep( + wallet: Wallet, + name: string, + parent: TelemetrySpan | undefined, + callback: () => Promise | T +): Promise { + if (parent == null) return await callback() + return await wallet.telemetry.withSpan( + name, + { + component: 'wallet-toolbox', + parent: parent.context + }, + callback + ) +} + +async function createNewTx( + wallet: Wallet, + vargs: Validation.ValidCreateActionArgs, + parent?: TelemetrySpan +): Promise { const logger = vargs.logger const storageArgs = removeUnlockScripts(vargs) - const dcr = (await wallet.actionBatch.plan(storageArgs)) ?? (await wallet.storage.createAction(storageArgs)) + const dcr = await traceActionStep( + wallet, + 'wallet.create_action.storage_plan', + parent, + async () => (await wallet.actionBatch.plan(storageArgs)) ?? (await wallet.storage.createAction(storageArgs)) + ) const reference = dcr.reference - const { tx, amount, pdi } = buildSignableTransaction(dcr, vargs, wallet) + const { tx, amount, pdi } = await traceActionStep( + wallet, + 'wallet.create_action.build_signable_transaction', + parent, + () => buildSignableTransaction(dcr, vargs, wallet) + ) logger?.log('built signable transaction') const prior: PendingSignAction = { reference, dcr, args: vargs, amount, tx, pdi } diff --git a/packages/wallet/wallet-toolbox/src/signer/methods/signAction.ts b/packages/wallet/wallet-toolbox/src/signer/methods/signAction.ts index 205a58981..2f116caf5 100644 --- a/packages/wallet/wallet-toolbox/src/signer/methods/signAction.ts +++ b/packages/wallet/wallet-toolbox/src/signer/methods/signAction.ts @@ -1,4 +1,13 @@ -import { AtomicBEEF, Beef, SendWithResult, SignActionArgs, SignActionResult, TXIDHexString, Validation } from '@bsv/sdk' +import { + AtomicBEEF, + Beef, + SendWithResult, + SignActionArgs, + SignActionResult, + TelemetrySpan, + TXIDHexString, + Validation +} from '@bsv/sdk' import { processAction } from './createAction' import { AuthId, ReviewActionResult } from '../../sdk/WalletStorage.interfaces' import { completeSignedTransaction, verifyUnlockScripts } from './completeSignedTransaction' @@ -13,24 +22,68 @@ export interface SignActionResultX extends SignActionResult { notDelayedResults?: ReviewActionResult[] } -export async function signAction (wallet: Wallet, auth: AuthId, args: SignActionArgs): Promise { +export async function signAction(wallet: Wallet, auth: AuthId, args: SignActionArgs): Promise { + if (!wallet.telemetry.enabled) return await signActionCore(wallet, auth, args) + return await wallet.telemetry.withSpan( + 'wallet.sign_action', + { + component: 'wallet-toolbox', + carrier: args + }, + async span => { + const result = await signActionCore(wallet, auth, args, span) + span.end({ + attributes: { + 'action.has_transaction': result.tx != null, + 'action.send_result_count': result.sendWithResults?.length ?? 0 + } + }) + return result + } + ) +} + +async function signActionCore( + wallet: Wallet, + auth: AuthId, + args: SignActionArgs, + parent?: TelemetrySpan +): Promise { const prior = wallet.pendingSignActions[args.reference] - if (!prior) { throw new WERR_NOT_IMPLEMENTED('recovery of out-of-session signAction reference data is not yet implemented.') } + if (!prior) { + throw new WERR_NOT_IMPLEMENTED('recovery of out-of-session signAction reference data is not yet implemented.') + } if (prior.dcr.inputBeef == null) throw new WERR_INTERNAL('prior.dcr.inputBeef must be valid') const vargs = mergePriorOptions(prior.args, args) - prior.tx = await completeSignedTransaction(prior, vargs.spends, wallet) + prior.tx = await traceSignActionStep( + wallet, + 'wallet.sign_action.complete_signing', + parent, + async () => await completeSignedTransaction(prior, vargs.spends, wallet) + ) - const { sendWithResults, notDelayedResults } = await processAction(prior, wallet, auth, vargs) + const { sendWithResults, notDelayedResults } = await traceSignActionStep( + wallet, + 'wallet.sign_action.process', + parent, + async () => await processAction(prior, wallet, auth, vargs) + ) const txid = prior.tx.id('hex') - const beef = prior.dcr.inputBeef instanceof Uint8Array - ? Beef.fromBinaryView(prior.dcr.inputBeef) - : Beef.fromBinary(prior.dcr.inputBeef) + const beef = + prior.dcr.inputBeef instanceof Uint8Array + ? Beef.fromBinaryView(prior.dcr.inputBeef) + : Beef.fromBinary(prior.dcr.inputBeef) beef.mergeTransaction(prior.tx) - await verifyUnlockScripts(txid, beef, wallet.scriptVerifier) + await traceSignActionStep( + wallet, + 'wallet.sign_action.verify_unlock_scripts', + parent, + async () => await verifyUnlockScripts(txid, beef, wallet.scriptVerifier) + ) const r: SignActionResultX = { txid: prior.tx.id('hex'), @@ -45,7 +98,24 @@ export async function signAction (wallet: Wallet, auth: AuthId, args: SignAction return r } -function mergePriorOptions ( +async function traceSignActionStep( + wallet: Wallet, + name: string, + parent: TelemetrySpan | undefined, + callback: () => Promise | T +): Promise { + if (parent == null) return await callback() + return await wallet.telemetry.withSpan( + name, + { + component: 'wallet-toolbox', + parent: parent.context + }, + callback + ) +} + +function mergePriorOptions( caVargs: Validation.ValidCreateActionArgs, saArgs: SignActionArgs ): Validation.ValidSignActionArgs { diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index f57ffb1d8..836739528 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -1,4 +1,4 @@ -import { ListActionsResult, ListOutputsResult, Validation } from '@bsv/sdk' +import { ListActionsResult, ListOutputsResult, TelemetrySpan, Validation } from '@bsv/sdk' import { outputColumnsWithoutLockingScript, TableCertificate, @@ -73,22 +73,71 @@ export interface StorageKnexOptions extends StorageProviderOptions { // ceilings. The surrounding transaction still makes a complete pack atomic. const ACTION_BATCH_BLOB_SQL_CHUNK = 500 +interface KnexTelemetryQuery { + __knexQueryUid?: string + method?: string +} + export class StorageKnex extends StorageProvider implements WalletStorageProvider { knex: Knex + private readonly querySpans = new Map() + private readonly onQuery = (query: KnexTelemetryQuery): void => { + if (!this.telemetry.enabled || query.__knexQueryUid == null) return + const span = this.telemetry.startSpan('wallet.storage.db.query', { + component: 'wallet-storage-knex', + kind: 'client', + attributes: { + 'db.system': this.databaseSystem(), + 'db.operation': query.method ?? 'unknown' + } + }) + this.querySpans.set(query.__knexQueryUid, span) + } + + private readonly onQueryResponse = (_response: unknown, query: KnexTelemetryQuery): void => { + this.endQuerySpan(query) + } + + private readonly onQueryError = (error: unknown, query: KnexTelemetryQuery): void => { + this.endQuerySpan(query, error) + } - constructor (options: StorageKnexOptions) { + constructor(options: StorageKnexOptions) { super(options) if (options.knex == null) throw new WERR_INVALID_PARAMETER('options.knex', 'valid') this.knex = options.knex + if (this.telemetry.enabled) { + this.knex.on('query', this.onQuery) + this.knex.on('query-response', this.onQueryResponse) + this.knex.on('query-error', this.onQueryError) + } + } + + private databaseSystem(): string { + const clientName = (this.knex.client as { config?: { client?: string } }).config?.client ?? '' + if (clientName.includes('sqlite')) return 'sqlite' + if (clientName.includes('mysql')) return 'mysql' + if (clientName.includes('pg') || clientName.includes('postgres')) return 'postgresql' + return 'unknown' } - protected override supportsActionBatchPersistence (): boolean { return true } + private endQuerySpan(query: KnexTelemetryQuery, error?: unknown): void { + if (query.__knexQueryUid == null) return + const span = this.querySpans.get(query.__knexQueryUid) + if (span == null) return + this.querySpans.delete(query.__knexQueryUid) + span.end(error == null ? {} : { status: 'error', error }) + } + + protected override supportsActionBatchPersistence(): boolean { + return true + } - async readSettings (): Promise { + async readSettings(): Promise { return this.validateEntity(verifyOne(await this.toDb()('settings'))) } - override async getProvenOrRawTx (txid: string, trx?: TrxToken): Promise { + override async getProvenOrRawTx(txid: string, trx?: TrxToken): Promise { const k = this.toDb(trx) const r: ProvenOrRawTx = { proven: undefined, @@ -112,17 +161,22 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return r } - dbTypeSubstring (source: string, fromOffset: number, forLength?: number): string { + dbTypeSubstring(source: string, fromOffset: number, forLength?: number): string { if (this.dbtype === 'MySQL') return `substring(${source} from ${fromOffset} for ${String(forLength)})` return `substr(${source}, ${fromOffset}, ${String(forLength)})` } - private normaliseKnexRawResult (rs: unknown): Array<{ rawTx: Buffer | null }> { + private normaliseKnexRawResult(rs: unknown): Array<{ rawTx: Buffer | null }> { if (this.dbtype === 'MySQL') return (rs as Array>)[0] return rs as Array<{ rawTx: Buffer | null }> } - private async getRawTxSlice (txid: string, offset: number, length: number, trx?: TrxToken): Promise { + private async getRawTxSlice( + txid: string, + offset: number, + length: number, + trx?: TrxToken + ): Promise { const sub = this.dbTypeSubstring('rawTx', offset + 1, length) let rs = await this.toDb(trx).raw(`select ${sub} as rawTx from proven_txs where txid = '${txid}'`) const proven = verifyOneOrNone(this.normaliseKnexRawResult(rs)) @@ -134,7 +188,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return req?.rawTx != null ? Array.from(req.rawTx) : undefined } - override async getRawTxOfKnownValidTransaction ( + override async getRawTxOfKnownValidTransaction( txid?: string, offset?: number, length?: number, @@ -149,7 +203,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return r.proven != null ? r.proven.rawTx : r.rawTx } - getProvenTxsForUserQuery (args: FindForUserSincePagedArgs): Knex.QueryBuilder { + getProvenTxsForUserQuery(args: FindForUserSincePagedArgs): Knex.QueryBuilder { const k = this.toDb(args.trx) let q = k('proven_txs').where(function () { void this.whereExists( @@ -167,13 +221,13 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return q } - override async getProvenTxsForUser (args: FindForUserSincePagedArgs): Promise { + override async getProvenTxsForUser(args: FindForUserSincePagedArgs): Promise { const q = this.getProvenTxsForUserQuery(args) const rs = await q return this.validateEntities(rs) } - getProvenTxReqsForUserQuery (args: FindForUserSincePagedArgs): Knex.QueryBuilder { + getProvenTxReqsForUserQuery(args: FindForUserSincePagedArgs): Knex.QueryBuilder { const k = this.toDb(args.trx) let q = k('proven_tx_reqs').where(function () { void this.whereExists( @@ -191,13 +245,13 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return q } - override async getProvenTxReqsForUser (args: FindForUserSincePagedArgs): Promise { + override async getProvenTxReqsForUser(args: FindForUserSincePagedArgs): Promise { const q = this.getProvenTxReqsForUserQuery(args) const rs = await q return this.validateEntities(rs, undefined, ['notified']) } - getTxLabelMapsForUserQuery (args: FindForUserSincePagedArgs): Knex.QueryBuilder { + getTxLabelMapsForUserQuery(args: FindForUserSincePagedArgs): Knex.QueryBuilder { const k = this.toDb(args.trx) let q = k('tx_labels_map').whereExists( k @@ -213,13 +267,13 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return q } - override async getTxLabelMapsForUser (args: FindForUserSincePagedArgs): Promise { + override async getTxLabelMapsForUser(args: FindForUserSincePagedArgs): Promise { const q = this.getTxLabelMapsForUserQuery(args) const rs = await q return this.validateEntities(rs, undefined, ['isDeleted']) } - getOutputTagMapsForUserQuery (args: FindForUserSincePagedArgs): Knex.QueryBuilder { + getOutputTagMapsForUserQuery(args: FindForUserSincePagedArgs): Knex.QueryBuilder { const k = this.toDb(args.trx) let q = k('output_tags_map').whereExists( k @@ -235,23 +289,23 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return q } - override async getOutputTagMapsForUser (args: FindForUserSincePagedArgs): Promise { + override async getOutputTagMapsForUser(args: FindForUserSincePagedArgs): Promise { const q = this.getOutputTagMapsForUserQuery(args) const rs = await q return this.validateEntities(rs, undefined, ['isDeleted']) } - override async listActions (auth: AuthId, vargs: Validation.ValidListActionsArgs): Promise { + override async listActions(auth: AuthId, vargs: Validation.ValidListActionsArgs): Promise { if (auth.userId == null) throw new WERR_UNAUTHORIZED() return await listActions(this, auth, vargs) } - override async listOutputs (auth: AuthId, vargs: Validation.ValidListOutputsArgs): Promise { + override async listOutputs(auth: AuthId, vargs: Validation.ValidListOutputsArgs): Promise { if (auth.userId == null) throw new WERR_UNAUTHORIZED() return await listOutputs(this, auth, vargs) } - override async insertProvenTx (tx: TableProvenTx, trx?: TrxToken): Promise { + override async insertProvenTx(tx: TableProvenTx, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(tx, trx) if (e.provenTxId === 0) delete e.provenTxId const [id] = await this.toDb(trx)('proven_txs').insert(e) @@ -259,7 +313,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return tx.provenTxId } - override async insertActionBatch (batch: TableActionBatch, trx?: TrxToken): Promise { + override async insertActionBatch(batch: TableActionBatch, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(batch, trx, ['expiresAt', 'hardExpiresAt']) if (e.actionBatchId === 0) delete e.actionBatchId const [id] = await this.toDb(trx)('action_batches').insert(e) @@ -267,7 +321,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return id } - override async findActionBatch ( + override async findActionBatch( userId: number, batchId: string, trx?: TrxToken @@ -276,19 +330,16 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return row == null ? undefined : this.validateEntity(row, ['expiresAt', 'hardExpiresAt']) } - override async findActionBatchForUpdate ( + override async findActionBatchForUpdate( userId: number, batchId: string, trx: TrxToken ): Promise { - const row = await this.toDb(trx)('action_batches') - .where({ userId, batchId }) - .forUpdate() - .first() + const row = await this.toDb(trx)('action_batches').where({ userId, batchId }).forUpdate().first() return row == null ? undefined : this.validateEntity(row, ['expiresAt', 'hardExpiresAt']) } - override async findExpiredActionBatches (now: Date, trx?: TrxToken): Promise { + override async findExpiredActionBatches(now: Date, trx?: TrxToken): Promise { const rows = await this.toDb(trx)('action_batches') .whereIn('status', ['active', 'prepared']) .andWhere(builder => { @@ -299,7 +350,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntities(rows, ['expiresAt', 'hardExpiresAt']) } - override async updateActionBatch ( + override async updateActionBatch( actionBatchId: number, update: Partial, trx?: TrxToken @@ -309,27 +360,24 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .update(this.validatePartialForUpdate(update, ['expiresAt', 'hardExpiresAt'])) } - override async deleteActionBatch (actionBatchId: number, trx?: TrxToken): Promise { + override async deleteActionBatch(actionBatchId: number, trx?: TrxToken): Promise { await this.toDb(trx)('action_batches').where({ actionBatchId }).delete() } - override async reserveActionBatchOutputs ( - reservations: TableActionBatchOutput[], - trx?: TrxToken - ): Promise { + override async reserveActionBatchOutputs(reservations: TableActionBatchOutput[], trx?: TrxToken): Promise { if (reservations.length === 0) return const rows = await Promise.all(reservations.map(async r => await this.validateEntityForInsert(r, trx))) await this.toDb(trx)('action_batch_outputs').insert(rows) } - override async findActionBatchOutputIds (actionBatchId: number, trx?: TrxToken): Promise { + override async findActionBatchOutputIds(actionBatchId: number, trx?: TrxToken): Promise { const rows = await this.toDb(trx)('action_batch_outputs') .where({ actionBatchId }) .select('outputId') return rows.map(r => r.outputId) } - override async findReservedActionBatchOutputIds (outputIds: number[], trx?: TrxToken): Promise { + override async findReservedActionBatchOutputIds(outputIds: number[], trx?: TrxToken): Promise { if (outputIds.length === 0) return [] const rows = await this.toDb(trx)('action_batch_outputs') .whereIn('outputId', outputIds) @@ -337,11 +385,11 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return rows.map(r => r.outputId) } - override async deleteActionBatchOutputReservations (actionBatchId: number, trx?: TrxToken): Promise { + override async deleteActionBatchOutputReservations(actionBatchId: number, trx?: TrxToken): Promise { await this.toDb(trx)('action_batch_outputs').where({ actionBatchId }).delete() } - override async putActionBatchBlobRecord (blob: TableActionBatchBlob, trx?: TrxToken): Promise { + override async putActionBatchBlobRecord(blob: TableActionBatchBlob, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(blob, trx) if (e.actionBatchBlobId === 0) delete e.actionBatchBlobId await this.toDb(trx)('action_batch_blobs') @@ -350,7 +398,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .ignore() } - override async findActionBatchBlobRecord ( + override async findActionBatchBlobRecord( actionBatchId: number, digest: string, trx?: TrxToken @@ -366,7 +414,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntity(row) } - override async findActionBatchBlobRecords ( + override async findActionBatchBlobRecords( actionBatchId: number, digests: string[], trx?: TrxToken @@ -375,9 +423,11 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide const rows: TableActionBatchBlob[] = [] const uniqueDigests = [...new Set(digests)] for (let offset = 0; offset < uniqueDigests.length; offset += ACTION_BATCH_BLOB_SQL_CHUNK) { - rows.push(...await this.toDb(trx)('action_batch_blobs') - .where({ actionBatchId }) - .whereIn('digest', uniqueDigests.slice(offset, offset + ACTION_BATCH_BLOB_SQL_CHUNK))) + rows.push( + ...(await this.toDb(trx)('action_batch_blobs') + .where({ actionBatchId }) + .whereIn('digest', uniqueDigests.slice(offset, offset + ACTION_BATCH_BLOB_SQL_CHUNK))) + ) } for (const row of rows) { if (Buffer.isBuffer(row.bytes)) { @@ -388,16 +438,15 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntities(rows) } - override async putActionBatchBlobRecords ( - blobs: TableActionBatchBlob[], - trx?: TrxToken - ): Promise { + override async putActionBatchBlobRecords(blobs: TableActionBatchBlob[], trx?: TrxToken): Promise { if (blobs.length === 0) return - const rows = await Promise.all(blobs.map(async blob => { - const row = await this.validateEntityForInsert(blob, trx) - if (row.actionBatchBlobId === 0) delete row.actionBatchBlobId - return row - })) + const rows = await Promise.all( + blobs.map(async blob => { + const row = await this.validateEntityForInsert(blob, trx) + if (row.actionBatchBlobId === 0) delete row.actionBatchBlobId + return row + }) + ) for (let offset = 0; offset < rows.length; offset += ACTION_BATCH_BLOB_SQL_CHUNK) { await this.toDb(trx)('action_batch_blobs') .insert(rows.slice(offset, offset + ACTION_BATCH_BLOB_SQL_CHUNK)) @@ -406,11 +455,11 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide } } - override async deleteActionBatchBlobRecords (actionBatchId: number, trx?: TrxToken): Promise { + override async deleteActionBatchBlobRecords(actionBatchId: number, trx?: TrxToken): Promise { await this.toDb(trx)('action_batch_blobs').where({ actionBatchId }).delete() } - override async insertProvenTxReq (tx: TableProvenTxReq, trx?: TrxToken): Promise { + override async insertProvenTxReq(tx: TableProvenTxReq, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(tx, trx) if (e.provenTxReqId === 0) delete e.provenTxReqId const [id] = await this.toDb(trx)('proven_tx_reqs').insert(e) @@ -418,7 +467,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return tx.provenTxReqId } - override async insertUser (user: TableUser, trx?: TrxToken): Promise { + override async insertUser(user: TableUser, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(user, trx) if (e.userId === 0) delete e.userId const [id] = await this.toDb(trx)('users').insert(e) @@ -426,13 +475,17 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return user.userId } - override async insertCertificateAuth (auth: AuthId, certificate: TableCertificateX): Promise { - if (auth.userId == null || (certificate.userId != null && certificate.userId !== 0 && certificate.userId !== auth.userId)) throw new WERR_UNAUTHORIZED() + override async insertCertificateAuth(auth: AuthId, certificate: TableCertificateX): Promise { + if ( + auth.userId == null || + (certificate.userId != null && certificate.userId !== 0 && certificate.userId !== auth.userId) + ) + throw new WERR_UNAUTHORIZED() certificate.userId = auth.userId return await this.insertCertificate(certificate) } - override async insertCertificate (certificate: TableCertificateX, trx?: TrxToken): Promise { + override async insertCertificate(certificate: TableCertificateX, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(certificate, trx, undefined, ['isDeleted']) if (e.certificateId === 0) delete e.certificateId @@ -455,12 +508,12 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return certificate.certificateId } - override async insertCertificateField (certificateField: TableCertificateField, trx?: TrxToken): Promise { + override async insertCertificateField(certificateField: TableCertificateField, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(certificateField, trx) await this.toDb(trx)('certificate_fields').insert(e) } - override async insertOutputBasket (basket: TableOutputBasket, trx?: TrxToken): Promise { + override async insertOutputBasket(basket: TableOutputBasket, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(basket, trx, undefined, ['isDeleted']) if (e.basketId === 0) delete e.basketId const [id] = await this.toDb(trx)('output_baskets').insert(e) @@ -468,7 +521,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return basket.basketId } - override async insertTransaction (tx: TableTransaction, trx?: TrxToken): Promise { + override async insertTransaction(tx: TableTransaction, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(tx, trx) if (e.transactionId === 0) delete e.transactionId const [id] = await this.toDb(trx)('transactions').insert(e) @@ -476,7 +529,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return tx.transactionId } - override async insertCommission (commission: TableCommission, trx?: TrxToken): Promise { + override async insertCommission(commission: TableCommission, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(commission, trx) if (e.commissionId === 0) delete e.commissionId const [id] = await this.toDb(trx)('commissions').insert(e) @@ -484,7 +537,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return commission.commissionId } - override async insertOutput (output: TableOutput, trx?: TrxToken): Promise { + override async insertOutput(output: TableOutput, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(output, trx) if (e.outputId === 0) delete e.outputId const [id] = await this.toDb(trx)('outputs').insert(e) @@ -492,7 +545,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return output.outputId } - override async insertOutputTag (tag: TableOutputTag, trx?: TrxToken): Promise { + override async insertOutputTag(tag: TableOutputTag, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(tag, trx, undefined, ['isDeleted']) if (e.outputTagId === 0) delete e.outputTagId const [id] = await this.toDb(trx)('output_tags').insert(e) @@ -500,12 +553,12 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return tag.outputTagId } - override async insertOutputTagMap (tagMap: TableOutputTagMap, trx?: TrxToken): Promise { + override async insertOutputTagMap(tagMap: TableOutputTagMap, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(tagMap, trx, undefined, ['isDeleted']) await this.toDb(trx)('output_tags_map').insert(e) } - override async insertTxLabel (label: TableTxLabel, trx?: TrxToken): Promise { + override async insertTxLabel(label: TableTxLabel, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(label, trx, undefined, ['isDeleted']) if (e.txLabelId === 0) delete e.txLabelId const [id] = await this.toDb(trx)('tx_labels').insert(e) @@ -513,12 +566,12 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return label.txLabelId } - override async insertTxLabelMap (labelMap: TableTxLabelMap, trx?: TrxToken): Promise { + override async insertTxLabelMap(labelMap: TableTxLabelMap, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(labelMap, trx, undefined, ['isDeleted']) await this.toDb(trx)('tx_labels_map').insert(e) } - override async insertMonitorEvent (event: TableMonitorEvent, trx?: TrxToken): Promise { + override async insertMonitorEvent(event: TableMonitorEvent, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(event, trx) if (e.id === 0) delete e.id const [id] = await this.toDb(trx)('monitor_events').insert(e) @@ -526,7 +579,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return event.id } - override async insertSyncState (syncState: TableSyncState, trx?: TrxToken): Promise { + override async insertSyncState(syncState: TableSyncState, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(syncState, trx, ['when'], ['init']) if (e.syncStateId === 0) delete e.syncStateId const [id] = await this.toDb(trx)('sync_states').insert(e) @@ -534,7 +587,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return syncState.syncStateId } - override async updateCertificateField ( + override async updateCertificateField( certificateId: number, fieldName: string, update: Partial, @@ -546,35 +599,35 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .update(this.validatePartialForUpdate(update)) } - override async updateCertificate (id: number, update: Partial, trx?: TrxToken): Promise { + override async updateCertificate(id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('certificates') .where({ certificateId: id }) .update(this.validatePartialForUpdate(update, undefined, ['isDeleted'])) } - override async updateCommission (id: number, update: Partial, trx?: TrxToken): Promise { + override async updateCommission(id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('commissions') .where({ commissionId: id }) .update(this.validatePartialForUpdate(update)) } - override async updateOutputBasket (id: number, update: Partial, trx?: TrxToken): Promise { + override async updateOutputBasket(id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('output_baskets') .where({ basketId: id }) .update(this.validatePartialForUpdate(update, undefined, ['isDeleted'])) } - override async updateOutput (id: number, update: Partial, trx?: TrxToken): Promise { + override async updateOutput(id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('outputs') .where({ outputId: id }) .update(this.validatePartialForUpdate(update)) } - override async updateOutputTagMap ( + override async updateOutputTagMap( outputId: number, tagId: number, update: Partial, @@ -586,14 +639,14 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .update(this.validatePartialForUpdate(update, undefined, ['isDeleted'])) } - override async updateOutputTag (id: number, update: Partial, trx?: TrxToken): Promise { + override async updateOutputTag(id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('output_tags') .where({ outputTagId: id }) .update(this.validatePartialForUpdate(update, undefined, ['isDeleted'])) } - override async updateProvenTxReq ( + override async updateProvenTxReq( id: number | number[], update: Partial, trx?: TrxToken @@ -614,21 +667,21 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return r } - override async updateProvenTx (id: number, update: Partial, trx?: TrxToken): Promise { + override async updateProvenTx(id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('proven_txs') .where({ provenTxId: id }) .update(this.validatePartialForUpdate(update)) } - override async updateSyncState (id: number, update: Partial, trx?: TrxToken): Promise { + override async updateSyncState(id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('sync_states') .where({ syncStateId: id }) .update(this.validatePartialForUpdate(update, ['when'], ['init'])) } - override async updateTransaction ( + override async updateTransaction( id: number | number[], update: Partial, trx?: TrxToken @@ -649,7 +702,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return r } - override async updateTxLabelMap ( + override async updateTxLabelMap( transactionId: number, txLabelId: number, update: Partial, @@ -661,19 +714,19 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .update(this.validatePartialForUpdate(update, undefined, ['isDeleted'])) } - override async updateTxLabel (id: number, update: Partial, trx?: TrxToken): Promise { + override async updateTxLabel(id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('tx_labels') .where({ txLabelId: id }) .update(this.validatePartialForUpdate(update, undefined, ['isDeleted'])) } - override async updateUser (id: number, update: Partial, trx?: TrxToken): Promise { + override async updateUser(id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('users').where({ userId: id }).update(this.validatePartialForUpdate(update)) } - override async updateMonitorEvent (id: number, update: Partial, trx?: TrxToken): Promise { + override async updateMonitorEvent(id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('monitor_events') .where({ id }) @@ -737,18 +790,18 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return q } - findCertificateFieldsQuery (args: FindCertificateFieldsArgs): Knex.QueryBuilder { + findCertificateFieldsQuery(args: FindCertificateFieldsArgs): Knex.QueryBuilder { return this.setupQuery('certificate_fields', args) } - findCertificatesQuery (args: FindCertificatesArgs): Knex.QueryBuilder { + findCertificatesQuery(args: FindCertificatesArgs): Knex.QueryBuilder { const q = this.setupQuery('certificates', args) - if ((args.certifiers != null) && args.certifiers.length > 0) void q.whereIn('certifier', args.certifiers) - if ((args.types != null) && args.types.length > 0) void q.whereIn('type', args.types) + if (args.certifiers != null && args.certifiers.length > 0) void q.whereIn('certifier', args.certifiers) + if (args.types != null && args.types.length > 0) void q.whereIn('type', args.types) return q } - findCommissionsQuery (args: FindCommissionsArgs): Knex.QueryBuilder { + findCommissionsQuery(args: FindCommissionsArgs): Knex.QueryBuilder { if (args.partial.lockingScript != null) { throw new WERR_INVALID_PARAMETER( 'partial.lockingScript', @@ -758,11 +811,11 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.setupQuery('commissions', args) } - findOutputBasketsQuery (args: FindOutputBasketsArgs): Knex.QueryBuilder { + findOutputBasketsQuery(args: FindOutputBasketsArgs): Knex.QueryBuilder { return this.setupQuery('output_baskets', args) } - findOutputsQuery (args: FindOutputsArgs, count?: boolean): Knex.QueryBuilder { + findOutputsQuery(args: FindOutputsArgs, count?: boolean): Knex.QueryBuilder { if (args.partial.lockingScript != null) { throw new WERR_INVALID_PARAMETER( 'args.partial.lockingScript', @@ -770,30 +823,32 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide ) } const q = this.setupQuery('outputs', args) - if ((args.txStatus != null) && args.txStatus.length > 0) { + if (args.txStatus != null && args.txStatus.length > 0) { void q.whereRaw( `(select status from transactions where transactions.transactionId = outputs.transactionId) in (${args.txStatus.map(s => "'" + s + "'").join(',')})` ) } - if ((args.noScript === true) && count !== true) { + if (args.noScript === true && count !== true) { const columns = outputColumnsWithoutLockingScript.map(c => `outputs.${c}`) void q.select(columns) } return q } - findOutputTagMapsQuery (args: FindOutputTagMapsArgs): Knex.QueryBuilder { + findOutputTagMapsQuery(args: FindOutputTagMapsArgs): Knex.QueryBuilder { const q = this.setupQuery('output_tags_map', args) - if ((args.tagIds != null) && args.tagIds.length > 0) void q.whereIn('outputTagId', args.tagIds) + if (args.tagIds != null && args.tagIds.length > 0) void q.whereIn('outputTagId', args.tagIds) return q } - findOutputTagsQuery (args: FindOutputTagsArgs): Knex.QueryBuilder { + findOutputTagsQuery(args: FindOutputTagsArgs): Knex.QueryBuilder { return this.setupQuery('output_tags', args) } - findProvenTxReqsQuery (args: FindProvenTxReqsArgs): Knex.QueryBuilder { - if (args.partial.rawTx != null) { throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. ProvenTxReqs may not be found by rawTx value.') } + findProvenTxReqsQuery(args: FindProvenTxReqsArgs): Knex.QueryBuilder { + if (args.partial.rawTx != null) { + throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. ProvenTxReqs may not be found by rawTx value.') + } if (args.partial.inputBEEF != null) { throw new WERR_INVALID_PARAMETER( 'args.partial.inputBEEF', @@ -801,7 +856,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide ) } const q = this.setupQuery('proven_tx_reqs', args) - if ((args.status != null) && args.status.length > 0) void q.whereIn('status', args.status) + if (args.status != null && args.status.length > 0) void q.whereIn('status', args.status) if (args.txids != null) { const txids = args.txids.filter(txid => txid !== undefined) if (txids.length > 0) void q.whereIn('txid', txids) @@ -809,8 +864,10 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return q } - findProvenTxsQuery (args: FindProvenTxsArgs): Knex.QueryBuilder { - if (args.partial.rawTx != null) { throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. ProvenTxs may not be found by rawTx value.') } + findProvenTxsQuery(args: FindProvenTxsArgs): Knex.QueryBuilder { + if (args.partial.rawTx != null) { + throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. ProvenTxs may not be found by rawTx value.') + } if (args.partial.merklePath != null) { throw new WERR_INVALID_PARAMETER( 'args.partial.merklePath', @@ -820,7 +877,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.setupQuery('proven_txs', args) } - findStaleMerkleRootsQuery (args: FindStaleMerkleRootsArgs): Knex.QueryBuilder { + findStaleMerkleRootsQuery(args: FindStaleMerkleRootsArgs): Knex.QueryBuilder { const q = this.toDb(args.trx)('proven_txs') void q.where('height', '=', args.height) void q.where('merkleRoot', '!=', args.merkleRoot) @@ -829,12 +886,14 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return q } - findSyncStatesQuery (args: FindSyncStatesArgs): Knex.QueryBuilder { + findSyncStatesQuery(args: FindSyncStatesArgs): Knex.QueryBuilder { return this.setupQuery('sync_states', args) } - findTransactionsQuery (args: FindTransactionsArgs, count?: boolean): Knex.QueryBuilder { - if (args.partial.rawTx != null) { throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. Transactions may not be found by rawTx value.') } + findTransactionsQuery(args: FindTransactionsArgs, count?: boolean): Knex.QueryBuilder { + if (args.partial.rawTx != null) { + throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. Transactions may not be found by rawTx value.') + } if (args.partial.inputBEEF != null) { throw new WERR_INVALID_PARAMETER( 'args.partial.inputBEEF', @@ -842,57 +901,69 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide ) } const q = this.setupQuery('transactions', args) - if ((args.status != null) && args.status.length > 0) void q.whereIn('status', args.status) + if (args.status != null && args.status.length > 0) void q.whereIn('status', args.status) if (args.from != null) void q.where('created_at', '>=', this.validateDateForWhere(args.from)) if (args.to != null) void q.where('created_at', '<', this.validateDateForWhere(args.to)) - if ((args.noRawTx === true) && count !== true) { + if (args.noRawTx === true && count !== true) { const columns = transactionColumnsWithoutRawTx.map(c => `transactions.${c}`) void q.select(columns) } return q } - findTxLabelMapsQuery (args: FindTxLabelMapsArgs): Knex.QueryBuilder { + findTxLabelMapsQuery(args: FindTxLabelMapsArgs): Knex.QueryBuilder { const q = this.setupQuery('tx_labels_map', args) - if ((args.labelIds != null) && args.labelIds.length > 0) void q.whereIn('txLabelId', args.labelIds) + if (args.labelIds != null && args.labelIds.length > 0) void q.whereIn('txLabelId', args.labelIds) return q } - findTxLabelsQuery (args: FindTxLabelsArgs): Knex.QueryBuilder { + findTxLabelsQuery(args: FindTxLabelsArgs): Knex.QueryBuilder { return this.setupQuery('tx_labels', args) } - findUsersQuery (args: FindUsersArgs): Knex.QueryBuilder { + findUsersQuery(args: FindUsersArgs): Knex.QueryBuilder { return this.setupQuery('users', args) } - findMonitorEventsQuery (args: FindMonitorEventsArgs): Knex.QueryBuilder { + findMonitorEventsQuery(args: FindMonitorEventsArgs): Knex.QueryBuilder { return this.setupQuery('monitor_events', args) } - override async findCertificatesAuth (auth: AuthId, args: FindCertificatesArgs): Promise { - if (auth.userId == null || (args.partial.userId != null && args.partial.userId !== 0 && args.partial.userId !== auth.userId)) throw new WERR_UNAUTHORIZED() + override async findCertificatesAuth(auth: AuthId, args: FindCertificatesArgs): Promise { + if ( + auth.userId == null || + (args.partial.userId != null && args.partial.userId !== 0 && args.partial.userId !== auth.userId) + ) + throw new WERR_UNAUTHORIZED() args.partial.userId = auth.userId return await this.findCertificates(args) } - override async findOutputBasketsAuth (auth: AuthId, args: FindOutputBasketsArgs): Promise { - if (auth.userId == null || (args.partial.userId != null && args.partial.userId !== 0 && args.partial.userId !== auth.userId)) throw new WERR_UNAUTHORIZED() + override async findOutputBasketsAuth(auth: AuthId, args: FindOutputBasketsArgs): Promise { + if ( + auth.userId == null || + (args.partial.userId != null && args.partial.userId !== 0 && args.partial.userId !== auth.userId) + ) + throw new WERR_UNAUTHORIZED() args.partial.userId = auth.userId return await this.findOutputBaskets(args) } - override async findOutputsAuth (auth: AuthId, args: FindOutputsArgs): Promise { - if (auth.userId == null || (args.partial.userId != null && args.partial.userId !== 0 && args.partial.userId !== auth.userId)) throw new WERR_UNAUTHORIZED() + override async findOutputsAuth(auth: AuthId, args: FindOutputsArgs): Promise { + if ( + auth.userId == null || + (args.partial.userId != null && args.partial.userId !== 0 && args.partial.userId !== auth.userId) + ) + throw new WERR_UNAUTHORIZED() args.partial.userId = auth.userId return await this.findOutputs(args) } - override async findCertificateFields (args: FindCertificateFieldsArgs): Promise { + override async findCertificateFields(args: FindCertificateFieldsArgs): Promise { return this.validateEntities(await this.findCertificateFieldsQuery(args)) } - override async findCertificates (args: FindCertificatesArgs): Promise { + override async findCertificates(args: FindCertificatesArgs): Promise { const q = this.findCertificatesQuery(args) let r: TableCertificateX[] = await q r = this.validateEntities(r, undefined, ['isDeleted']) @@ -909,19 +980,19 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return r } - override async findCommissions (args: FindCommissionsArgs): Promise { + override async findCommissions(args: FindCommissionsArgs): Promise { const q = this.findCommissionsQuery(args) const r = await q return this.validateEntities(r, undefined, ['isRedeemed']) } - override async findOutputBaskets (args: FindOutputBasketsArgs): Promise { + override async findOutputBaskets(args: FindOutputBasketsArgs): Promise { const q = this.findOutputBasketsQuery(args) const r = await q return this.validateEntities(r, undefined, ['isDeleted']) } - override async findOutputs (args: FindOutputsArgs): Promise { + override async findOutputs(args: FindOutputsArgs): Promise { const q = this.findOutputsQuery(args) const r = await q if (args.noScript !== true) { @@ -932,43 +1003,43 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntities(r, undefined, ['spendable', 'change']) } - override async findOutputTagMaps (args: FindOutputTagMapsArgs): Promise { + override async findOutputTagMaps(args: FindOutputTagMapsArgs): Promise { const q = this.findOutputTagMapsQuery(args) const r = await q return this.validateEntities(r, undefined, ['isDeleted']) } - override async findOutputTags (args: FindOutputTagsArgs): Promise { + override async findOutputTags(args: FindOutputTagsArgs): Promise { const q = this.findOutputTagsQuery(args) const r = await q return this.validateEntities(r, undefined, ['isDeleted']) } - override async findProvenTxReqs (args: FindProvenTxReqsArgs): Promise { + override async findProvenTxReqs(args: FindProvenTxReqsArgs): Promise { const q = this.findProvenTxReqsQuery(args) const r = await q return this.validateEntities(r, undefined, ['notified', 'wasBroadcast']) } - override async findProvenTxs (args: FindProvenTxsArgs): Promise { + override async findProvenTxs(args: FindProvenTxsArgs): Promise { const q = this.findProvenTxsQuery(args) const r = await q return this.validateEntities(r) } - override async findStaleMerkleRoots (args: FindStaleMerkleRootsArgs): Promise { + override async findStaleMerkleRoots(args: FindStaleMerkleRootsArgs): Promise { const q = this.findStaleMerkleRootsQuery(args) const r = await q return r.map((row: { merkleRoot: string }) => row.merkleRoot) } - override async findSyncStates (args: FindSyncStatesArgs): Promise { + override async findSyncStates(args: FindSyncStatesArgs): Promise { const q = this.findSyncStatesQuery(args) const r = await q return this.validateEntities(r, ['when'], ['init']) } - override async findTransactions (args: FindTransactionsArgs): Promise { + override async findTransactions(args: FindTransactionsArgs): Promise { const q = this.findTransactionsQuery(args) const r = await q if (args.noRawTx !== true) { @@ -979,25 +1050,25 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntities(r, undefined, ['isOutgoing']) } - override async findTxLabelMaps (args: FindTxLabelMapsArgs): Promise { + override async findTxLabelMaps(args: FindTxLabelMapsArgs): Promise { const q = this.findTxLabelMapsQuery(args) const r = await q return this.validateEntities(r, undefined, ['isDeleted']) } - override async findTxLabels (args: FindTxLabelsArgs): Promise { + override async findTxLabels(args: FindTxLabelsArgs): Promise { const q = this.findTxLabelsQuery(args) const r = await q return this.validateEntities(r, undefined, ['isDeleted']) } - override async findUsers (args: FindUsersArgs): Promise { + override async findUsers(args: FindUsersArgs): Promise { const q = this.findUsersQuery(args) const r = await q return this.validateEntities(r) } - override async recentlyActiveUsers (limit = 50, trx?: TrxToken): Promise { + override async recentlyActiveUsers(limit = 50, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) const latestOutputs = this.toDb(trx)('outputs as o') .select('o.userId') @@ -1014,7 +1085,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntities(rows as TableUser[]) } - override async findMonitorEvents (args: FindMonitorEventsArgs): Promise { + override async findMonitorEvents(args: FindMonitorEventsArgs): Promise { const q = this.findMonitorEventsQuery(args) const r = await q return this.validateEntities(r, ['when']) @@ -1026,71 +1097,76 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return r[0]['count(*)'] } - override async countCertificateFields (args: FindCertificateFieldsArgs): Promise { + override async countCertificateFields(args: FindCertificateFieldsArgs): Promise { return await this.getCount(this.findCertificateFieldsQuery(args)) } - override async countCertificates (args: FindCertificatesArgs): Promise { + override async countCertificates(args: FindCertificatesArgs): Promise { return await this.getCount(this.findCertificatesQuery(args)) } - override async countCommissions (args: FindCommissionsArgs): Promise { + override async countCommissions(args: FindCommissionsArgs): Promise { return await this.getCount(this.findCommissionsQuery(args)) } - override async countOutputBaskets (args: FindOutputBasketsArgs): Promise { + override async countOutputBaskets(args: FindOutputBasketsArgs): Promise { return await this.getCount(this.findOutputBasketsQuery(args)) } - override async countOutputs (args: FindOutputsArgs): Promise { + override async countOutputs(args: FindOutputsArgs): Promise { return await this.getCount(this.findOutputsQuery(args, true)) } - override async countOutputTagMaps (args: FindOutputTagMapsArgs): Promise { + override async countOutputTagMaps(args: FindOutputTagMapsArgs): Promise { return await this.getCount(this.findOutputTagMapsQuery(args)) } - override async countOutputTags (args: FindOutputTagsArgs): Promise { + override async countOutputTags(args: FindOutputTagsArgs): Promise { return await this.getCount(this.findOutputTagsQuery(args)) } - override async countProvenTxReqs (args: FindProvenTxReqsArgs): Promise { + override async countProvenTxReqs(args: FindProvenTxReqsArgs): Promise { return await this.getCount(this.findProvenTxReqsQuery(args)) } - override async countProvenTxs (args: FindProvenTxsArgs): Promise { + override async countProvenTxs(args: FindProvenTxsArgs): Promise { return await this.getCount(this.findProvenTxsQuery(args)) } - override async countSyncStates (args: FindSyncStatesArgs): Promise { + override async countSyncStates(args: FindSyncStatesArgs): Promise { return await this.getCount(this.findSyncStatesQuery(args)) } - override async countTransactions (args: FindTransactionsArgs): Promise { + override async countTransactions(args: FindTransactionsArgs): Promise { return await this.getCount(this.findTransactionsQuery(args, true)) } - override async countTxLabelMaps (args: FindTxLabelMapsArgs): Promise { + override async countTxLabelMaps(args: FindTxLabelMapsArgs): Promise { return await this.getCount(this.findTxLabelMapsQuery(args)) } - override async countTxLabels (args: FindTxLabelsArgs): Promise { + override async countTxLabels(args: FindTxLabelsArgs): Promise { return await this.getCount(this.findTxLabelsQuery(args)) } - override async countUsers (args: FindUsersArgs): Promise { + override async countUsers(args: FindUsersArgs): Promise { return await this.getCount(this.findUsersQuery(args)) } - override async countMonitorEvents (args: FindMonitorEventsArgs): Promise { + override async countMonitorEvents(args: FindMonitorEventsArgs): Promise { return await this.getCount(this.findMonitorEventsQuery(args)) } - override async destroy (): Promise { + override async destroy(): Promise { + this.knex.off('query', this.onQuery) + this.knex.off('query-response', this.onQueryResponse) + this.knex.off('query-error', this.onQueryError) + for (const span of this.querySpans.values()) span.end({ status: 'cancelled' }) + this.querySpans.clear() await this.knex?.destroy() } - override async migrate (storageName: string, storageIdentityKey: string): Promise { + override async migrate(storageName: string, storageIdentityKey: string): Promise { // Check if this is a SQLite database by looking at the Knex client config const clientName = (this.knex.client as { config?: { client?: string } }).config?.client ?? '' const isSQLite = clientName.includes('sqlite') @@ -1118,7 +1194,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return version } - override async dropAllData (): Promise { + override async dropAllData(): Promise { // Only using migrations to migrate down, don't need valid properties for settings table. const migrationSource = new KnexMigrations('test', '', '', 1024) @@ -1177,14 +1253,14 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide * or a Knex.Transaction as appropriate. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - toDb (trx?: TrxToken): Knex | Knex.Transaction { + toDb(trx?: TrxToken): Knex | Knex.Transaction { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = (trx == null) ? this.knex : trx as Knex.Transaction + const db = trx == null ? this.knex : (trx as Knex.Transaction) this.whenLastAccess = new Date() return db } - async validateRawTransaction (t: TableTransaction, trx?: TrxToken): Promise { + async validateRawTransaction(t: TableTransaction, trx?: TrxToken): Promise { // if there is no txid or there is a rawTransaction return what we have. if (t.rawTx != null || t.txid == null || t.txid === '') return @@ -1205,7 +1281,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide * * @param trx */ - async verifyReadyForDatabaseAccess (trx?: TrxToken): Promise { + async verifyReadyForDatabaseAccess(trx?: TrxToken): Promise { this._settings ??= await this.readSettings() // Always run the PRAGMA for SQLite to ensure foreign key constraints are enabled. @@ -1223,7 +1299,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide /** Convert every byte-array value to a Buffer and every undefined to null on an arbitrary object. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - private serialiseForKnex (v: any): void { + private serialiseForKnex(v: any): void { for (const key of Object.keys(v)) { const val = v[key] if (Array.isArray(val) && (val.length === 0 || typeof val[0] === 'number')) { @@ -1238,7 +1314,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide /** Apply optional date-field coercion list in-place. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - private coerceDateFields (v: any, dateFields?: string[]): void { + private coerceDateFields(v: any, dateFields?: string[]): void { if (dateFields == null) return for (const df of dateFields) { if (v[df] != null) v[df] = this.validateOptionalEntityDate(v[df]) @@ -1247,7 +1323,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide /** Apply optional boolean-field coercion list in-place. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - private coerceBooleanFields (v: any, booleanFields?: string[]): void { + private coerceBooleanFields(v: any, booleanFields?: string[]): void { if (booleanFields == null) return for (const df of booleanFields) { if (v[df] !== undefined) v[df] = (v[df] as unknown) != null && (v[df] as unknown) !== false ? 1 : 0 @@ -1286,7 +1362,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide trx?: TrxToken, dateFields?: string[], booleanFields?: string[] - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { await this.verifyReadyForDatabaseAccess(trx) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1302,7 +1378,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return v } - override async getLabelsForTransactionId (transactionId?: number, trx?: TrxToken): Promise { + override async getLabelsForTransactionId(transactionId?: number, trx?: TrxToken): Promise { if (transactionId === undefined) return [] const labels = await this.toDb(trx)('tx_labels') .join('tx_labels_map', 'tx_labels_map.txLabelId', 'tx_labels.txLabelId') @@ -1312,7 +1388,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntities(labels, undefined, ['isDeleted']) } - override async getTagsForOutputId (outputId: number, trx?: TrxToken): Promise { + override async getTagsForOutputId(outputId: number, trx?: TrxToken): Promise { const tags = await this.toDb(trx)('output_tags') .join('output_tags_map', 'output_tags_map.outputTagId', 'output_tags.outputTagId') .where('output_tags_map.outputId', outputId) @@ -1321,11 +1397,11 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntities(tags, undefined, ['isDeleted']) } - override async purgeData (params: PurgeParams, trx?: TrxToken): Promise { + override async purgeData(params: PurgeParams, trx?: TrxToken): Promise { return await purgeData(this, params, trx) } - override async reviewStatus (args: { agedLimit: Date, trx?: TrxToken }): Promise<{ log: string }> { + override async reviewStatus(args: { agedLimit: Date; trx?: TrxToken }): Promise<{ log: string }> { return await reviewStatus(this, args) } @@ -1337,7 +1413,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide * - unproven * - sending (if excludeSending is false) */ - async countChangeInputs (userId: number, basketId: number, excludeSending: boolean): Promise { + async countChangeInputs(userId: number, basketId: number, excludeSending: boolean): Promise { const status: TransactionStatus[] = ['completed', 'unproven'] if (!excludeSending) status.push('sending') const q = this.knex('outputs as o') @@ -1361,7 +1437,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return count } - override async findOutputsByIds (outputIds: number[], trx?: TrxToken): Promise> { + override async findOutputsByIds(outputIds: number[], trx?: TrxToken): Promise> { const byId: Record = {} if (outputIds.length < 1) return byId const rows = await this.toDb(trx)('outputs').whereIn('outputId', outputIds).select('*') @@ -1375,9 +1451,9 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return byId } - override async findOutputsByOutpoints ( + override async findOutputsByOutpoints( userId: number, - outpoints: Array<{ txid: string, vout: number }>, + outpoints: Array<{ txid: string; vout: number }>, trx?: TrxToken ): Promise> { const byOutpoint: Record = {} @@ -1400,9 +1476,9 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return byOutpoint } - override async findOutputsByOutpointsForUpdate ( + override async findOutputsByOutpointsForUpdate( userId: number, - outpoints: Array<{ txid: string, vout: number }>, + outpoints: Array<{ txid: string; vout: number }>, trx: TrxToken ): Promise> { const byOutpoint: Record = {} @@ -1422,7 +1498,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return byOutpoint } - override async findOrInsertOutputBasketsBulk ( + override async findOrInsertOutputBasketsBulk( userId: number, names: string[], trx?: TrxToken @@ -1444,7 +1520,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return byName } - override async findOrInsertOutputTagsBulk ( + override async findOrInsertOutputTagsBulk( userId: number, tags: string[], trx?: TrxToken @@ -1466,7 +1542,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return byTag } - override async findOrInsertTxLabelsBulk ( + override async findOrInsertTxLabelsBulk( userId: number, labels: string[], trx?: TrxToken @@ -1488,7 +1564,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return byLabel } - override async sumSpendableSatoshisInBasket ( + override async sumSpendableSatoshisInBasket( userId: number, basketId: number, excludeSending: boolean, @@ -1503,7 +1579,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .whereIn('t.status', status) .sum({ totalSatoshis: 'o.satoshis' }) .first() - const total = (row != null) ? (row as Record).totalSatoshis : undefined + const total = row != null ? (row as Record).totalSatoshis : undefined return Number(total ?? 0) } @@ -1512,7 +1588,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide * * Transactionally allocate the output such that */ - async allocateChangeInput ( + async allocateChangeInput( userId: number, basketId: number, targetSatoshis: number, @@ -1542,9 +1618,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .whereNot('o.derivationSuffix', '') .whereNull('o.spentBy') .whereNotExists(function () { - void this.select(1) - .from('action_batch_outputs as abo') - .whereRaw('abo.outputId = o.outputId') + void this.select(1).from('action_batch_outputs as abo').whereRaw('abo.outputId = o.outputId') }) .whereIn('t.status', status) .select('o.*') @@ -1622,7 +1696,9 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide if (booleanFields != null) { for (const df of booleanFields) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((entity as any)[df] !== undefined) (entity as any)[df] = (entity as any)[df] !== 0 && (entity as any)[df] != null && (entity as any)[df] !== false + if ((entity as any)[df] !== undefined) + (entity as any)[df] = + (entity as any)[df] !== 0 && (entity as any)[df] != null && (entity as any)[df] !== false } } this.deserialiseFromKnex(entity) @@ -1641,7 +1717,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return entities } - async adminStats (adminIdentityKey: string): Promise { + async adminStats(adminIdentityKey: string): Promise { if (this.dbtype !== 'MySQL') throw new WERR_NOT_IMPLEMENTED('adminStats, only MySQL is supported') const monitorEvent = verifyOneOrNone( @@ -1651,7 +1727,8 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide paged: { limit: 1 } }) ) - const monitorStats: ServicesCallHistory | undefined = (monitorEvent != null) ? JSON.parse(monitorEvent.details as string) : undefined + const monitorStats: ServicesCallHistory | undefined = + monitorEvent != null ? JSON.parse(monitorEvent.details as string) : undefined const servicesStats = this.getServices().getServicesCallHistory(true) const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageReader.ts b/packages/wallet/wallet-toolbox/src/storage/StorageReader.ts index df5d2deb7..70f074bb2 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageReader.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageReader.ts @@ -19,6 +19,7 @@ import { import * as sdk from '../sdk/index' import { validateSecondsSinceEpoch, verifyOneOrNone, verifyTruthy } from '../utility/utilityHelpers' import { getSyncChunk } from './methods/getSyncChunk' +import { Telemetry, TelemetryConfig } from '@bsv/sdk' type StorageDate = Date | string type DateInput = Date | string | number @@ -38,75 +39,78 @@ type OptionalDateInput = DateInput | null | undefined */ export abstract class StorageReader implements sdk.WalletStorageSyncReader { chain: sdk.Chain + readonly telemetry: Telemetry _settings?: TableSettings whenLastAccess?: Date - get dbtype (): DBType | undefined { + get dbtype(): DBType | undefined { return this._settings?.dbtype } - constructor (options: StorageReaderOptions) { + constructor(options: StorageReaderOptions) { this.chain = options.chain + this.telemetry = new Telemetry(options.telemetry) } - isAvailable (): boolean { + isAvailable(): boolean { return this._settings != null } - async makeAvailable (): Promise { + async makeAvailable(): Promise { if (this._settings != null) return this._settings this._settings = await this.readSettings() return this._settings } - getSettings (): TableSettings { - if (this._settings == null) throw new sdk.WERR_INVALID_OPERATION('must call "makeAvailable" before accessing "settings"') + getSettings(): TableSettings { + if (this._settings == null) + throw new sdk.WERR_INVALID_OPERATION('must call "makeAvailable" before accessing "settings"') return this._settings } - isStorageProvider (): boolean { + isStorageProvider(): boolean { return false } - abstract destroy (): Promise + abstract destroy(): Promise abstract transaction(scope: (trx: sdk.TrxToken) => Promise, trx?: sdk.TrxToken): Promise - abstract readSettings (trx?: sdk.TrxToken): Promise - - abstract findCertificateFields (args: sdk.FindCertificateFieldsArgs): Promise - abstract findCertificates (args: sdk.FindCertificatesArgs): Promise - abstract findCommissions (args: sdk.FindCommissionsArgs): Promise - abstract findMonitorEvents (args: sdk.FindMonitorEventsArgs): Promise - abstract findOutputBaskets (args: sdk.FindOutputBasketsArgs): Promise - abstract findOutputs (args: sdk.FindOutputsArgs): Promise - abstract findOutputTags (args: sdk.FindOutputTagsArgs): Promise - abstract findSyncStates (args: sdk.FindSyncStatesArgs): Promise - abstract findTransactions (args: sdk.FindTransactionsArgs): Promise - abstract findTxLabels (args: sdk.FindTxLabelsArgs): Promise - abstract findUsers (args: sdk.FindUsersArgs): Promise - - abstract countCertificateFields (args: sdk.FindCertificateFieldsArgs): Promise - abstract countCertificates (args: sdk.FindCertificatesArgs): Promise - abstract countCommissions (args: sdk.FindCommissionsArgs): Promise - abstract countMonitorEvents (args: sdk.FindMonitorEventsArgs): Promise - abstract countOutputBaskets (args: sdk.FindOutputBasketsArgs): Promise - abstract countOutputs (args: sdk.FindOutputsArgs): Promise - abstract countOutputTags (args: sdk.FindOutputTagsArgs): Promise - abstract countSyncStates (args: sdk.FindSyncStatesArgs): Promise - abstract countTransactions (args: sdk.FindTransactionsArgs): Promise - abstract countTxLabels (args: sdk.FindTxLabelsArgs): Promise - abstract countUsers (args: sdk.FindUsersArgs): Promise - - abstract getProvenTxsForUser (args: sdk.FindForUserSincePagedArgs): Promise - abstract getProvenTxReqsForUser (args: sdk.FindForUserSincePagedArgs): Promise - abstract getTxLabelMapsForUser (args: sdk.FindForUserSincePagedArgs): Promise - abstract getOutputTagMapsForUser (args: sdk.FindForUserSincePagedArgs): Promise - - async findUserByIdentityKey (key: string): Promise { + abstract readSettings(trx?: sdk.TrxToken): Promise + + abstract findCertificateFields(args: sdk.FindCertificateFieldsArgs): Promise + abstract findCertificates(args: sdk.FindCertificatesArgs): Promise + abstract findCommissions(args: sdk.FindCommissionsArgs): Promise + abstract findMonitorEvents(args: sdk.FindMonitorEventsArgs): Promise + abstract findOutputBaskets(args: sdk.FindOutputBasketsArgs): Promise + abstract findOutputs(args: sdk.FindOutputsArgs): Promise + abstract findOutputTags(args: sdk.FindOutputTagsArgs): Promise + abstract findSyncStates(args: sdk.FindSyncStatesArgs): Promise + abstract findTransactions(args: sdk.FindTransactionsArgs): Promise + abstract findTxLabels(args: sdk.FindTxLabelsArgs): Promise + abstract findUsers(args: sdk.FindUsersArgs): Promise + + abstract countCertificateFields(args: sdk.FindCertificateFieldsArgs): Promise + abstract countCertificates(args: sdk.FindCertificatesArgs): Promise + abstract countCommissions(args: sdk.FindCommissionsArgs): Promise + abstract countMonitorEvents(args: sdk.FindMonitorEventsArgs): Promise + abstract countOutputBaskets(args: sdk.FindOutputBasketsArgs): Promise + abstract countOutputs(args: sdk.FindOutputsArgs): Promise + abstract countOutputTags(args: sdk.FindOutputTagsArgs): Promise + abstract countSyncStates(args: sdk.FindSyncStatesArgs): Promise + abstract countTransactions(args: sdk.FindTransactionsArgs): Promise + abstract countTxLabels(args: sdk.FindTxLabelsArgs): Promise + abstract countUsers(args: sdk.FindUsersArgs): Promise + + abstract getProvenTxsForUser(args: sdk.FindForUserSincePagedArgs): Promise + abstract getProvenTxReqsForUser(args: sdk.FindForUserSincePagedArgs): Promise + abstract getTxLabelMapsForUser(args: sdk.FindForUserSincePagedArgs): Promise + abstract getOutputTagMapsForUser(args: sdk.FindForUserSincePagedArgs): Promise + + async findUserByIdentityKey(key: string): Promise { return verifyOneOrNone(await this.findUsers({ partial: { identityKey: key } })) } - async getSyncChunk (args: sdk.RequestSyncChunkArgs): Promise { + async getSyncChunk(args: sdk.RequestSyncChunkArgs): Promise { return await getSyncChunk(this, args) } @@ -115,7 +119,7 @@ export abstract class StorageReader implements sdk.WalletStorageSyncReader { * @param date * @returns */ - validateEntityDate (date: DateInput): StorageDate { + validateEntityDate(date: DateInput): StorageDate { if (this.dbtype == null) throw new sdk.WERR_INTERNAL('must call verifyReadyForDatabaseAccess first') let r: StorageDate = this.validateDate(date) switch (this.dbtype) { @@ -137,13 +141,10 @@ export abstract class StorageReader implements sdk.WalletStorageSyncReader { * @param useNowAsDefault if true and date is null or undefiend, set to current time. * @returns */ - validateOptionalEntityDate ( - date: OptionalDateInput, - useNowAsDefault?: boolean - ): StorageDate | undefined { + validateOptionalEntityDate(date: OptionalDateInput, useNowAsDefault?: boolean): StorageDate | undefined { if (this.dbtype == null) throw new sdk.WERR_INTERNAL('must call verifyReadyForDatabaseAccess first') let r: StorageDate | undefined = this.validateOptionalDate(date) - if ((r == null) && (useNowAsDefault === true)) r = new Date() + if (r == null && useNowAsDefault === true) r = new Date() switch (this.dbtype) { case 'IndexedDB': case 'MySQL': @@ -157,19 +158,19 @@ export abstract class StorageReader implements sdk.WalletStorageSyncReader { return r } - validateDate (date: DateInput): Date { + validateDate(date: DateInput): Date { let r: Date if (date instanceof Date) r = date else r = new Date(date) return r } - validateOptionalDate (date: OptionalDateInput): Date | undefined { + validateOptionalDate(date: OptionalDateInput): Date | undefined { if (date === null || date === undefined) return undefined return this.validateDate(date) } - validateDateForWhere (date: DateInput): DateInput { + validateDateForWhere(date: DateInput): DateInput { if (this.dbtype == null) throw new sdk.WERR_INTERNAL('must call verifyReadyForDatabaseAccess first') if (typeof date === 'number') date = validateSecondsSinceEpoch(date) const vdate = verifyTruthy(this.validateDate(date)) @@ -191,6 +192,8 @@ export abstract class StorageReader implements sdk.WalletStorageSyncReader { export interface StorageReaderOptions { chain: sdk.Chain + /** Optional provider-neutral storage and database tracing. */ + telemetry?: TelemetryConfig } export type DBType = 'SQLite' | 'MySQL' | 'IndexedDB' diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnex.telemetry.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnex.telemetry.test.ts new file mode 100644 index 000000000..31830fd5c --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnex.telemetry.test.ts @@ -0,0 +1,69 @@ +import { EventEmitter } from 'node:events' +import type { TelemetryEvent } from '@bsv/sdk' +import { StorageKnex } from '../StorageKnex' + +describe('StorageKnex telemetry', () => { + it('times parallel Knex queries by query UID without SQL, bindings, or rows', async () => { + const events: TelemetryEvent[] = [] + let nextSpanId = 1 + const knex = new EventEmitter() as any + knex.client = { config: { client: 'better-sqlite3' } } + knex.destroy = jest.fn(async () => undefined) + const storage = new StorageKnex({ + ...StorageKnex.defaultOptions(), + chain: 'test', + knex, + telemetry: { + sink: { + capture: event => events.push(event) + }, + traceIdFactory: () => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanIdFactory: () => (nextSpanId++).toString(16).padStart(16, '0') + } + }) + const first = { + __knexQueryUid: 'query-1', + method: 'select', + sql: 'select secret from users where identityKey = ?', + bindings: ['never-report-this'] + } + const second = { + __knexQueryUid: 'query-2', + method: 'insert', + sql: 'insert into certificates values (?)', + bindings: ['private-certificate'] + } + + knex.emit('query', first) + knex.emit('query', second) + knex.emit('query-response', [{ identityKey: 'private' }], first) + knex.emit('query-error', new Error('database unavailable'), second) + + expect(events).toHaveLength(2) + expect(events[0]).toMatchObject({ + name: 'wallet.storage.db.query', + spanStatus: 'ok', + attributes: { + 'db.system': 'sqlite', + 'db.operation': 'select' + } + }) + expect(events[1]).toMatchObject({ + name: 'wallet.storage.db.query', + spanStatus: 'error', + attributes: { + 'db.system': 'sqlite', + 'db.operation': 'insert' + } + }) + expect(JSON.stringify(events)).not.toContain('select secret') + expect(JSON.stringify(events)).not.toContain('never-report-this') + expect(JSON.stringify(events)).not.toContain('private-certificate') + expect(JSON.stringify(events)).not.toContain('query-1') + + knex.emit('query', { __knexQueryUid: 'query-pending', method: 'delete' }) + await storage.destroy() + expect(events.at(-1)?.spanStatus).toBe('cancelled') + expect(knex.destroy).toHaveBeenCalled() + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/__test/GenerateChange/generateChangeSdk.test.ts b/packages/wallet/wallet-toolbox/src/storage/methods/__test/GenerateChange/generateChangeSdk.test.ts index 788fc101f..d75ce61f0 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/__test/GenerateChange/generateChangeSdk.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/__test/GenerateChange/generateChangeSdk.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { Transaction as BsvTransaction, Script } from '@bsv/sdk' +import { Telemetry, Transaction as BsvTransaction, Script } from '@bsv/sdk' import { randomValsUsed1 } from './randomValsUsed1' import { sdk } from '../../../../index.client' import { @@ -1157,6 +1157,58 @@ describe('generateChange tests', () => { } expectTransactionSize(params, r) }) + + test('11 emits correlated allocation and generate-change spans without values', async () => { + const events: any[] = [] + const telemetry = new Telemetry({ + sink: { + capture: event => events.push(event) + }, + traceIdFactory: () => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanIdFactory: (() => { + let value = 1 + return () => (value++).toString(16).padStart(16, '0') + })() + }) + const params: GenerateChangeSdkParams = { + ...defParams, + fixedOutputs: [ + { satoshis: 1234, lockingScriptLength: 1739091 }, + { satoshis: 2, lockingScriptLength: 25 } + ] + } + const { allocateChangeInput, releaseChangeInput } = + generateChangeSdkMakeStorage([...defAvailableChange()]) + + await generateChangeSdk( + params, + allocateChangeInput, + releaseChangeInput, + undefined, + telemetry + ) + + const total = events.find(event => event.name === 'wallet.storage.generate_change') + const allocation = events.find(event => event.name === 'wallet.storage.generate_change.allocate') + expect(total).toMatchObject({ + type: 'span', + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanStatus: 'ok', + attributes: { + 'change.fixed_input_count': params.fixedInputs.length, + 'change.fixed_output_count': params.fixedOutputs.length, + 'change.allocated_input_count': 1 + } + }) + expect(allocation).toMatchObject({ + type: 'span', + traceId: total.traceId, + parentSpanId: total.spanId, + spanStatus: 'ok' + }) + expect(JSON.stringify(events)).not.toContain('6323') + expect(JSON.stringify(events)).not.toContain('15005') + }) }) // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts b/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts index 176a765d7..d7bd3c62a 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/createAction.ts @@ -943,7 +943,7 @@ async function fundNewTransactionSdk( }) } - const gcr = await generateChangeSdk(params, allocateChangeInput, releaseChangeInput, vargs.logger) + const gcr = await generateChangeSdk(params, allocateChangeInput, releaseChangeInput, vargs.logger, storage.telemetry) const nextRandomVal = (): number => { let val = 0 diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/generateChange.ts b/packages/wallet/wallet-toolbox/src/storage/methods/generateChange.ts index 50e82712e..9c895fe2f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/generateChange.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/generateChange.ts @@ -1,4 +1,4 @@ -import { Random, Validation, WalletLoggerInterface } from '@bsv/sdk' +import { Random, Telemetry, TelemetrySpan, Validation, WalletLoggerInterface } from '@bsv/sdk' import { WalletError } from '../../sdk/WalletError' import { StorageFeeModel } from '../../sdk/WalletStorage.interfaces' import { WERR_INSUFFICIENT_FUNDS, WERR_INTERNAL, WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' @@ -113,6 +113,80 @@ function removeDustOutputs(changeOutputs: GenerateChangeSdkChangeOutput[], dustF * @returns */ export async function generateChangeSdk( + params: GenerateChangeSdkParams, + allocateChangeInput: ( + targetSatoshis: number, + exactSatoshis?: number + ) => Promise, + releaseChangeInput: (outputId: number) => Promise, + logger?: WalletLoggerInterface, + telemetry?: Telemetry +): Promise { + if (telemetry?.enabled !== true) { + return await generateChangeSdkCore(params, allocateChangeInput, releaseChangeInput, logger) + } + + return await telemetry.withSpan( + 'wallet.storage.generate_change', + { + component: 'wallet-storage', + carrier: params, + attributes: { + 'change.fixed_input_count': params.fixedInputs.length, + 'change.fixed_output_count': params.fixedOutputs.length, + 'change.target_net_count': params.targetNetCount ?? 0 + } + }, + async span => { + const allocate = async ( + targetSatoshis: number, + exactSatoshis?: number + ): Promise => + await traceGenerateChangeStep( + telemetry, + span, + 'wallet.storage.generate_change.allocate', + async () => await allocateChangeInput(targetSatoshis, exactSatoshis) + ) + const release = async (outputId: number): Promise => { + await traceGenerateChangeStep( + telemetry, + span, + 'wallet.storage.generate_change.release', + async () => await releaseChangeInput(outputId) + ) + } + const result = await generateChangeSdkCore(params, allocate, release, logger) + span.end({ + attributes: { + 'change.allocated_input_count': result.allocatedChangeInputs.length, + 'change.output_count': result.changeOutputs.length, + 'change.transaction_size_bytes': result.size, + 'change.fee_satoshis': result.fee + } + }) + return result + } + ) +} + +async function traceGenerateChangeStep( + telemetry: Telemetry, + parent: TelemetrySpan, + name: string, + callback: () => Promise +): Promise { + return await telemetry.withSpan( + name, + { + component: 'wallet-storage', + parent: parent.context + }, + callback + ) +} + +async function generateChangeSdkCore( params: GenerateChangeSdkParams, allocateChangeInput: ( targetSatoshis: number, @@ -287,12 +361,12 @@ export async function generateChangeSdk( // per-transaction cap and ensure each output meets the dust floor. while ( r.changeOutputs.length < maxChangeOutputs && - ((hasTargetNetCount && targetNetCount > netChangeCount()) || - (r.changeOutputs.length === 0 && feeExcess() > 0)) + ((hasTargetNetCount && targetNetCount > netChangeCount()) || (r.changeOutputs.length === 0 && feeExcess() > 0)) ) { - const satoshis = r.changeOutputs.length === 0 - ? Math.max(dustFloor, params.changeFirstSatoshis) - : Math.max(dustFloor, params.changeInitialSatoshis) + const satoshis = + r.changeOutputs.length === 0 + ? Math.max(dustFloor, params.changeFirstSatoshis) + : Math.max(dustFloor, params.changeInitialSatoshis) r.changeOutputs.push({ satoshis, lockingScriptLength: params.changeLockingScriptLength diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts index 7678a1981..50ab61e8d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts @@ -1,6 +1,7 @@ import { WalletInterface, WalletLoggerInterface } from '@bsv/sdk' import { WalletErrorFromJson } from '../../sdk/WalletErrorFromJson' import { logWalletError } from '../../WalletLogger' +import { formatTraceparent } from '../../utility/traceContext' import { StorageClientBase, type StorageClientOptions } from './StorageClientBase' import { BINARY_ENCODING, @@ -36,74 +37,118 @@ export class StorageClient extends StorageClientBase { /** * Make a JSON-RPC call to the remote server. * @param method The WalletStorage method name to call. - * @param params The array of parameters to pass to the method in order. - */ + * @param params The array of parameters to pass to the method in order. + */ protected async rpcCall(method: string, params: unknown[]): Promise { - const requestOptions = - typeof params[1] === 'object' && params[1] !== null ? (params[1] as Record) : undefined - const logger = requestOptions?.logger as WalletLoggerInterface | undefined + return await this.traceRpcCall(method, params, async rpcSpan => { + const requestOptions = + typeof params[1] === 'object' && params[1] !== null ? (params[1] as Record) : undefined + const logger = requestOptions?.logger as WalletLoggerInterface | undefined - try { - const id = this.nextId++ + try { + const id = this.nextId++ - if (logger != null) { - // Replace logger object with seed json object to continue logging on request server. - logger.group(`StorageClient ${method}`) - requestOptions!.logger = { indent: logger.indent || 0 } - } + if (logger != null) { + // Replace logger object with seed json object to continue logging on request server. + logger.group(`StorageClient ${method}`) + requestOptions!.logger = { indent: logger.indent || 0 } + } - const body = { - jsonrpc: '2.0', - method, - params, - id - } + const body = { + jsonrpc: '2.0', + method, + params, + id + } - let response: Response - try { const requestUsesBinary = this.binaryRequests && this.serverSupportsBinary - response = await this.authClient.fetch(this.endpointUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - [BINARY_ENCODING_HEADER]: BINARY_ENCODING, - ...(requestUsesBinary ? { [BINARY_REQUEST_ENCODING_HEADER]: BINARY_ENCODING } : {}) - }, - body: stringifyJsonRpc(body, requestUsesBinary) - }) - } catch (error_: unknown) { - logWalletError(error_, logger, 'error requesting remote service') - throw error_ - } + const requestBody = await this.traceRpcStep( + 'wallet.storage.request.serialize', + rpcSpan, + () => stringifyJsonRpc(body, requestUsesBinary), + { 'rpc.encoding': requestUsesBinary ? 'binary-json' : 'json' } + ) - if (!response.ok) { - throw new Error(`WalletStorageClient rpcCall: network error ${response.status} ${response.statusText}`) - } + let response: Response + try { + response = await this.traceRpcStep( + 'wallet.storage.http', + rpcSpan, + async httpSpan => + await this.authClient.fetch(this.endpointUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [BINARY_ENCODING_HEADER]: BINARY_ENCODING, + ...(requestUsesBinary ? { [BINARY_REQUEST_ENCODING_HEADER]: BINARY_ENCODING } : {}), + ...(httpSpan == null || formatTraceparent(httpSpan.context) == null + ? {} + : { traceparent: formatTraceparent(httpSpan.context)! }) + }, + body: requestBody + }), + { + 'http.request.method': 'POST', + 'rpc.encoding': requestUsesBinary ? 'binary-json' : 'json' + } + ) + } catch (error_: unknown) { + logWalletError(error_, logger, 'error requesting remote service') + throw error_ + } - const responseUsesBinary = response.headers.get(BINARY_ENCODING_HEADER) === BINARY_ENCODING - if (responseUsesBinary) this.serverSupportsBinary = true - const json = parseJsonRpc(await response.text(), responseUsesBinary) - if (json.error) { - logWalletError(json.error, logger, 'error from remote service') - const werr = WalletErrorFromJson(json.error) - throw werr - } + if (!response.ok) { + throw new Error(`WalletStorageClient rpcCall: network error ${response.status} ${response.statusText}`) + } - if (logger != null) { - // merge log data from request processing - logger.merge?.(json.result?.log) - logger.groupEnd() - } + const responseUsesBinary = response.headers.get(BINARY_ENCODING_HEADER) === BINARY_ENCODING + if (responseUsesBinary) this.serverSupportsBinary = true + const responseText = await this.traceRpcStep( + 'wallet.storage.response.read', + rpcSpan, + async () => await response.text(), + { + 'http.response.status_code': response.status, + 'rpc.encoding': responseUsesBinary ? 'binary-json' : 'json' + } + ) + const json = await this.traceRpcStep( + 'wallet.storage.response.parse', + rpcSpan, + () => parseJsonRpc(responseText, responseUsesBinary), + { + 'rpc.encoding': responseUsesBinary ? 'binary-json' : 'json', + 'response.size_bytes': responseText.length + } + ) + if (json.error) { + logWalletError(json.error, logger, 'error from remote service') + const werr = WalletErrorFromJson(json.error) + throw werr + } + + if (logger != null) { + // merge log data from request processing + logger.merge?.(json.result?.log) + logger.groupEnd() + } - return json.result - } catch (error_: unknown) { - logWalletError(error_, logger, 'error setting up request to remote service') - throw error_ - } finally { - if (logger != null) { - // Restore original logger in params - requestOptions!.logger = logger + rpcSpan?.end({ + attributes: { + 'http.response.status_code': response.status, + 'rpc.encoding': responseUsesBinary ? 'binary-json' : 'json' + } + }) + return json.result + } catch (error_: unknown) { + logWalletError(error_, logger, 'error setting up request to remote service') + throw error_ + } finally { + if (logger != null) { + // Restore original logger in params + requestOptions!.logger = logger + } } - } + }) } } diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts index c09bee0d7..397931a3e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts @@ -9,7 +9,10 @@ import { RelinquishOutputArgs, WalletInterface, AuthFetch, - Validation + Validation, + Telemetry, + TelemetryConfig, + TelemetrySpan } from '@bsv/sdk' import { AuthId, @@ -69,6 +72,11 @@ export interface StorageClientOptions { * still route requests to legacy server instances. */ binaryRequests?: boolean + /** + * Optional vendor-neutral tracing. Disabled unless an enabled sink is + * supplied. Request parameters and response payloads are never emitted. + */ + telemetry?: TelemetryConfig } /** @@ -84,6 +92,7 @@ export abstract class StorageClientBase implements WalletStorageProvider { protected nextId = 1 protected serverSupportsBinary = false protected readonly binaryRequests: boolean + protected readonly telemetry: Telemetry // Track ephemeral (in-memory) "settings" if you wish to align with isAvailable() checks public settings?: TableSettings @@ -92,6 +101,49 @@ export abstract class StorageClientBase implements WalletStorageProvider { this.authClient = new AuthFetch(wallet) this.endpointUrl = endpointUrl this.binaryRequests = options.binaryRequests === true + this.telemetry = new Telemetry(options.telemetry) + } + + protected async traceRpcCall( + method: string, + params: unknown[], + callback: (span?: TelemetrySpan) => Promise + ): Promise { + if (!this.telemetry.enabled) return await callback() + const carrier = params.find(param => typeof param === 'object' && param != null) as object | undefined + return await this.telemetry.withSpan( + 'wallet.storage.rpc', + { + component: 'wallet-storage-client', + kind: 'client', + carrier, + attributes: { + 'rpc.method': method, + 'rpc.system': 'wallet-storage', + 'network.protocol': 'http' + } + }, + callback + ) + } + + protected async traceRpcStep( + name: string, + parent: TelemetrySpan | undefined, + callback: (span?: TelemetrySpan) => Promise | T, + attributes?: Readonly> + ): Promise { + if (parent == null) return await callback() + return await this.telemetry.withSpan( + name, + { + component: 'wallet-storage-client', + kind: 'client', + parent: parent.context, + attributes + }, + callback + ) } /** diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageMobile.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageMobile.ts index 57da71836..e5d5a311f 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageMobile.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageMobile.ts @@ -1,4 +1,5 @@ import { WalletInterface } from '@bsv/sdk' +import { formatTraceparent } from '../../utility/traceContext' import { StorageClientBase, type StorageClientOptions } from './StorageClientBase' import { BINARY_ENCODING, @@ -35,41 +36,84 @@ export class StorageClient extends StorageClientBase { * @param params The array of parameters to pass to the method in order. */ protected async rpcCall(method: string, params: unknown[]): Promise { - const id = this.nextId++ - const body = { - jsonrpc: '2.0', - method, - params, - id - } + return await this.traceRpcCall(method, params, async rpcSpan => { + const id = this.nextId++ + const body = { + jsonrpc: '2.0', + method, + params, + id + } - const requestUsesBinary = this.binaryRequests && this.serverSupportsBinary - const response = await this.authClient.fetch(this.endpointUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - [BINARY_ENCODING_HEADER]: BINARY_ENCODING, - ...(requestUsesBinary ? { [BINARY_REQUEST_ENCODING_HEADER]: BINARY_ENCODING } : {}) - }, - body: stringifyJsonRpc(body, requestUsesBinary) - }) + const requestUsesBinary = this.binaryRequests && this.serverSupportsBinary + const requestBody = await this.traceRpcStep( + 'wallet.storage.request.serialize', + rpcSpan, + () => stringifyJsonRpc(body, requestUsesBinary), + { 'rpc.encoding': requestUsesBinary ? 'binary-json' : 'json' } + ) + const response = await this.traceRpcStep( + 'wallet.storage.http', + rpcSpan, + async httpSpan => + await this.authClient.fetch(this.endpointUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [BINARY_ENCODING_HEADER]: BINARY_ENCODING, + ...(requestUsesBinary ? { [BINARY_REQUEST_ENCODING_HEADER]: BINARY_ENCODING } : {}), + ...(httpSpan == null || formatTraceparent(httpSpan.context) == null + ? {} + : { traceparent: formatTraceparent(httpSpan.context)! }) + }, + body: requestBody + }), + { + 'http.request.method': 'POST', + 'rpc.encoding': requestUsesBinary ? 'binary-json' : 'json' + } + ) - if (!response.ok) { - throw new Error(`WalletStorageClient rpcCall: network error ${response.status} ${response.statusText}`) - } + if (!response.ok) { + throw new Error(`WalletStorageClient rpcCall: network error ${response.status} ${response.statusText}`) + } - const responseUsesBinary = response.headers.get(BINARY_ENCODING_HEADER) === BINARY_ENCODING - if (responseUsesBinary) this.serverSupportsBinary = true - const json = parseJsonRpc(await response.text(), responseUsesBinary) - if (json.error) { - const { code, message, data } = json.error - const err = new Error(`RPC Error: ${message}`) - // You could attach more info here if you like: - ;(err as any).code = code - ;(err as any).data = data - throw err - } + const responseUsesBinary = response.headers.get(BINARY_ENCODING_HEADER) === BINARY_ENCODING + if (responseUsesBinary) this.serverSupportsBinary = true + const responseText = await this.traceRpcStep( + 'wallet.storage.response.read', + rpcSpan, + async () => await response.text(), + { + 'http.response.status_code': response.status, + 'rpc.encoding': responseUsesBinary ? 'binary-json' : 'json' + } + ) + const json = await this.traceRpcStep( + 'wallet.storage.response.parse', + rpcSpan, + () => parseJsonRpc(responseText, responseUsesBinary), + { + 'rpc.encoding': responseUsesBinary ? 'binary-json' : 'json', + 'response.size_bytes': responseText.length + } + ) + if (json.error) { + const { code, message, data } = json.error + const err = new Error(`RPC Error: ${message}`) + // You could attach more info here if you like: + ;(err as any).code = code + ;(err as any).data = data + throw err + } - return json.result + rpcSpan?.end({ + attributes: { + 'http.response.status_code': response.status, + 'rpc.encoding': responseUsesBinary ? 'binary-json' : 'json' + } + }) + return json.result + }) } } diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts index 7fa6f927b..05d9f6720 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts @@ -5,7 +5,14 @@ * and exposes it via a JSON-RPC POST endpoint using Express. */ -import { MakeWalletLogger, WalletInterface, WalletLoggerInterface } from '@bsv/sdk' +import { + MakeWalletLogger, + WalletInterface, + WalletLoggerInterface, + Telemetry, + TelemetryConfig, + TelemetrySpan +} from '@bsv/sdk' import express, { Request, Response } from 'express' import { AuthMiddlewareOptions, AuthRequest, createAuthMiddleware } from '@bsv/auth-express-middleware' import { createPaymentMiddleware } from '@bsv/payment-express-middleware' @@ -18,6 +25,7 @@ import { EntityTimeStamp } from '../../sdk/types' import { validateDate, validateEntity, validateEntities, validateSyncChunkEntities } from './entityValidationHelpers' import { WalletError } from '../../sdk/WalletError' import { logWalletError } from '../../WalletLogger' +import { parseTraceparent } from '../../utility/traceContext' import { BINARY_ENCODING, BINARY_ENCODING_HEADER, @@ -177,6 +185,11 @@ export interface WalletStorageServerOptions { logRpcRequests?: boolean /** @deprecated Use structured RPC logging. Retained for compatibility. */ logShortReqs?: boolean + /** + * Optional provider-neutral timing for ingress, authentication, RPC + * authorization, storage dispatch, and response formulation. + */ + telemetry?: TelemetryConfig } export class StorageServer { @@ -197,6 +210,8 @@ export class StorageServer { private readonly httpPolicy: HttpServerPolicyDefaults private readonly securityHeadersPolicy: SecurityHeadersOptions private readonly logRpcRequests: boolean + private readonly telemetry: Telemetry + private readonly telemetryConfig?: TelemetryConfig constructor(storage: StorageProvider, options: WalletStorageServerOptions) { this.storage = storage @@ -222,6 +237,8 @@ export class StorageServer { } this.securityHeadersPolicy = options.securityHeaders ?? {} this.logRpcRequests = options.logRpcRequests ?? true + this.telemetryConfig = options.telemetry + this.telemetry = new Telemetry(options.telemetry) const legacyLogShortReqs = (options as unknown as Record)['logShortReqs'] if (legacyLogShortReqs) { @@ -288,6 +305,9 @@ export class StorageServer { ) ) ) + if (this.telemetry.enabled) { + this.app.use(this.traceHttpRequest.bind(this)) + } // Escape HTML-significant characters in JSON responses. This preserves the // decoded JSON value while keeping user-controlled strings inert even if a @@ -319,7 +339,8 @@ export class StorageServer { }) const options: AuthMiddlewareOptions = { - wallet: this.wallet as WalletInterface + wallet: this.wallet as WalletInterface, + ...(this.telemetryConfig == null ? {} : { telemetry: this.telemetryConfig }) } if (this.sessionManager != null) options.sessionManager = this.sessionManager this.app.use(createAuthMiddleware(options)) @@ -395,6 +416,23 @@ export class StorageServer { } private async handleRpcRequest(req: Request, res: Response): Promise { + if (!this.telemetry.enabled) return await this.handleRpcRequestCore(req, res) + return await this.telemetry.withSpan( + 'wallet.storage.rpc', + { + component: 'wallet-storage-server', + kind: 'server', + carrier: req, + attributes: { + 'rpc.system': 'wallet-storage', + 'rpc.method': typeof req.body?.method === 'string' ? req.body.method : 'invalid' + } + }, + async span => await this.handleRpcRequestCore(req, res, span) + ) + } + + private async handleRpcRequestCore(req: Request, res: Response, rpcSpan?: TelemetrySpan): Promise { const useBinary = req.header(BINARY_ENCODING_HEADER) === BINARY_ENCODING const requestUsesBinary = req.header(BINARY_REQUEST_ENCODING_HEADER) === BINARY_ENCODING if (useBinary) res.set(BINARY_ENCODING_HEADER, BINARY_ENCODING) @@ -407,7 +445,7 @@ export class StorageServer { const logObj = this.createRpcLog(req, method, id, params) try { - const dispatch = await this.dispatchRpcCall(method, params, req, logObj) + const dispatch = await this.dispatchRpcCall(method, params, req, logObj, rpcSpan) if (!dispatch.found) { return this.sendRpc( res, @@ -426,6 +464,39 @@ export class StorageServer { } } + private traceHttpRequest(req: Request, res: Response, next: express.NextFunction): void { + const span = this.telemetry.startSpan('wallet.storage.http.request', { + component: 'wallet-storage-server', + kind: 'server', + parent: parseTraceparent(req.headers.traceparent), + carrier: req, + attributes: { + 'http.request.method': req.method, + 'http.request.body_size': Number(req.headers['content-length'] ?? 0), + 'network.protocol': 'http' + } + }) + span.bind(req) + let ended = false + const end = (status: 'ok' | 'error' | 'cancelled'): void => { + if (ended) return + ended = true + span.end({ + status, + attributes: { + 'http.response.status_code': res.statusCode + } + }) + } + res.once('finish', () => { + end(res.statusCode >= 500 ? 'error' : 'ok') + }) + res.once('close', () => { + end(res.writableEnded ? 'ok' : 'cancelled') + }) + next() + } + private sendRpc(res: Response, useBinary: boolean, payload: unknown, status: number = 200): Response { res.set('X-Content-Type-Options', 'nosniff') // Normalize with the negotiated binary replacer, then let Express emit @@ -452,11 +523,14 @@ export class StorageServer { const logObj: Record = { source: 'StorageServer POST handler', method, - id, - user: requiredAuthenticatedIdentityKey(req), - params: JSON.stringify(params || '').slice(0, 256) + requestIdType: typeof id, + authenticated: requiredAuthenticatedIdentityKey(req) !== '', + parameterCount: Array.isArray(params) ? params.length : 0 } - const traceContext = firstRequestHeader(req, 'X-Cloud-Trace-Context')?.split('/')[0] + const traceContext = + this.telemetry.contextFor(req)?.traceId ?? + parseTraceparent(firstRequestHeader(req, 'traceparent'))?.traceId ?? + firstRequestHeader(req, 'X-Cloud-Trace-Context')?.split('/')[0] if (traceContext) { logObj['logging.googleapis.com/trace'] = `projects/computing-with-integrity/traces/${traceContext}` } @@ -468,7 +542,8 @@ export class StorageServer { method: string, params: any[], req: Request, - logObj?: Record + logObj?: Record, + rpcSpan?: TelemetrySpan ): Promise { if (!storageRpcMethods.has(method)) return { found: false } @@ -476,12 +551,22 @@ export class StorageServer { const storageHandler = (this.storage as any)[storageMethod] if (typeof storageHandler !== 'function') return { found: false } - const shouldInvoke = await this.authorizeRpcCall(method, params, req, logObj) + const shouldInvoke = await this.traceRpcStep( + 'wallet.storage.authorize', + rpcSpan, + async () => await this.authorizeRpcCall(method, params, req, logObj), + { 'rpc.method': method } + ) if (!shouldInvoke) return { found: true, result: undefined } const logger = this.createRpcLogger(method, params) try { - const result = await storageHandler.call(this.storage, ...params) + const result = await this.traceRpcStep( + 'wallet.storage.handler', + rpcSpan, + async () => await storageHandler.call(this.storage, ...params), + { 'rpc.method': method } + ) this.finishRpcLogging(logger, result) return { found: true, result } } catch (error: unknown) { @@ -491,6 +576,25 @@ export class StorageServer { } } + private async traceRpcStep( + name: string, + parent: TelemetrySpan | undefined, + callback: () => Promise | T, + attributes?: Readonly> + ): Promise { + if (parent == null) return await callback() + return await this.telemetry.withSpan( + name, + { + component: 'wallet-storage-server', + kind: 'server', + parent: parent.context, + attributes + }, + callback + ) + } + private async authorizeRpcCall( method: string, params: any[], diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts new file mode 100644 index 000000000..ff9f8cc0d --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts @@ -0,0 +1,71 @@ +import type { TelemetryEvent, WalletInterface } from '@bsv/sdk' +import { StorageClient } from '../StorageClient' + +describe('StorageClient telemetry', () => { + it('propagates traceparent and records serialization, HTTP, read, and parse phases', async () => { + const events: TelemetryEvent[] = [] + let nextSpanId = 1 + let requestInit: RequestInit | undefined + const client = new StorageClient( + {} as WalletInterface, + 'https://storage.example.test/rpc', + { + telemetry: { + sink: { + capture: event => events.push(event) + }, + traceIdFactory: () => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanIdFactory: () => (nextSpanId++).toString(16).padStart(16, '0') + } + } + ) + Reflect.set(client, 'authClient', { + fetch: jest.fn(async (_url: string, init: RequestInit) => { + requestInit = init + return new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { available: true } + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ) + }) + }) + + const result = await Reflect.get(client, 'rpcCall').call( + client, + 'isAvailable', + [{ userId: 1 }] + ) + + expect(result).toEqual({ available: true }) + expect((requestInit?.headers as Record).traceparent).toMatch( + /^00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-[0-9a-f]{16}-01$/ + ) + const byName = new Map(events.map(event => [event.name, event])) + expect([ + 'wallet.storage.request.serialize', + 'wallet.storage.http', + 'wallet.storage.response.read', + 'wallet.storage.response.parse', + 'wallet.storage.rpc' + ].every(name => byName.has(name))).toBe(true) + const rpc = byName.get('wallet.storage.rpc')! + expect(rpc).toMatchObject({ + type: 'span', + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanStatus: 'ok', + attributes: { + 'rpc.method': 'isAvailable', + 'http.response.status_code': 200 + } + }) + expect(byName.get('wallet.storage.http')?.parentSpanId).toBe(rpc.spanId) + expect(JSON.stringify(events)).not.toContain('userId') + expect(JSON.stringify(events)).not.toContain('available') + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/utility/__tests__/traceContext.test.ts b/packages/wallet/wallet-toolbox/src/utility/__tests__/traceContext.test.ts new file mode 100644 index 000000000..ed974e93b --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/utility/__tests__/traceContext.test.ts @@ -0,0 +1,30 @@ +import { formatTraceparent, parseTraceparent } from '../traceContext' + +describe('wallet-toolbox trace-context compatibility', () => { + const traceId = '1'.repeat(32) + const spanId = '2'.repeat(16) + + it('round-trips a valid W3C version-00 header', () => { + const header = formatTraceparent({ traceId, spanId, traceFlags: 1 }) + expect(header).toBe(`00-${traceId}-${spanId}-01`) + expect(parseTraceparent(header)).toEqual({ traceId, spanId, traceFlags: 1 }) + }) + + it.each([ + undefined, + 42, + '', + 'x'.repeat(129), + `01-${traceId}-${spanId}-01`, + `00-${'0'.repeat(32)}-${spanId}-01`, + `00-${traceId}-${'0'.repeat(16)}-01` + ])('ignores malformed traceparent value %p', value => { + expect(parseTraceparent(value)).toBeUndefined() + }) + + it('refuses invalid IDs and clamps trace flags', () => { + expect(formatTraceparent({ traceId: 'invalid', spanId })).toBeUndefined() + expect(formatTraceparent({ traceId, spanId: 'invalid' })).toBeUndefined() + expect(formatTraceparent({ traceId, spanId, traceFlags: 999 })?.endsWith('-ff')).toBe(true) + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/utility/traceContext.ts b/packages/wallet/wallet-toolbox/src/utility/traceContext.ts new file mode 100644 index 000000000..ad949b0ec --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/utility/traceContext.ts @@ -0,0 +1,36 @@ +interface TraceContextLike { + traceId: string + spanId: string + traceFlags?: number +} + +const TRACEPARENT_PATTERN = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i + +function validTraceId(value: string): boolean { + return /^[0-9a-f]{32}$/i.test(value) && !/^0{32}$/.test(value) +} + +function validSpanId(value: string): boolean { + return /^[0-9a-f]{16}$/i.test(value) && !/^0{16}$/.test(value) +} + +/** + * Package-local compatibility helpers keep remoting loadable with older SDK + * peers while the equivalent public SDK helpers roll out. + */ +export function formatTraceparent(context: TraceContextLike): string | undefined { + if (!validTraceId(context.traceId) || !validSpanId(context.spanId)) return undefined + const flags = Math.max(0, Math.min(255, context.traceFlags ?? 1)) + return `00-${context.traceId.toLowerCase()}-${context.spanId.toLowerCase()}-${flags.toString(16).padStart(2, '0')}` +} + +export function parseTraceparent(value: unknown): TraceContextLike | undefined { + if (typeof value !== 'string' || value.length > 128) return undefined + const match = TRACEPARENT_PATTERN.exec(value.trim()) + if (match == null || !validTraceId(match[1]) || !validSpanId(match[2])) return undefined + return { + traceId: match[1].toLowerCase(), + spanId: match[2].toLowerCase(), + traceFlags: Number.parseInt(match[3], 16) + } +} From 2346d73ded3867a47f12070d78baa6904e1f4a13 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 29 Jul 2026 18:22:26 -0700 Subject: [PATCH 2/9] refactor: satisfy telemetry quality gate --- packages/sdk/src/telemetry/Telemetry.ts | 75 +++++++++++-------------- 1 file changed, 32 insertions(+), 43 deletions(-) diff --git a/packages/sdk/src/telemetry/Telemetry.ts b/packages/sdk/src/telemetry/Telemetry.ts index 003c4976b..066256979 100644 --- a/packages/sdk/src/telemetry/Telemetry.ts +++ b/packages/sdk/src/telemetry/Telemetry.ts @@ -280,46 +280,38 @@ function sanitizeAttributes( return count > 0 ? safe : undefined } +interface ErrorCandidate { + name?: unknown + message?: unknown + code?: unknown + stack?: unknown +} + +function sanitizeOptionalErrorText(value: unknown, maxLength: number): string | undefined { + return typeof value === 'string' ? sanitizeTelemetryText(value, maxLength) : undefined +} + +function sanitizeErrorCandidate(candidate: ErrorCandidate, includeStack: boolean): TelemetryError { + const message = + typeof candidate.message === 'string' && candidate.message.length > 0 + ? candidate.message + : 'Unknown error' + const code = sanitizeOptionalErrorText(candidate.code, 120) + const stack = includeStack + ? sanitizeOptionalErrorText(candidate.stack, MAX_STACK_LENGTH) + : undefined + return { + name: sanitizeName(candidate.name, 'Error'), + message: sanitizeTelemetryText(message), + ...(code !== undefined ? { code } : {}), + ...(stack !== undefined ? { stack } : {}) + } +} + function sanitizeError(error: unknown, includeStack: boolean): TelemetryError | undefined { if (error == null) return undefined - if (error instanceof Error) { - const withCode = error as Error & { code?: unknown } - const code = - typeof withCode.code === 'string' ? sanitizeTelemetryText(withCode.code, 120) : undefined - const stack = - includeStack && typeof error.stack === 'string' - ? sanitizeTelemetryText(error.stack, MAX_STACK_LENGTH) - : undefined - return { - name: sanitizeName(error.name, 'Error'), - message: sanitizeTelemetryText(error.message.length > 0 ? error.message : 'Unknown error'), - ...(code !== undefined ? { code } : {}), - ...(stack !== undefined ? { stack } : {}) - } - } - if (typeof error === 'object') { - const candidate = error as { - name?: unknown - message?: unknown - code?: unknown - stack?: unknown - } - const code = - typeof candidate.code === 'string' ? sanitizeTelemetryText(candidate.code, 120) : undefined - const stack = - includeStack && typeof candidate.stack === 'string' - ? sanitizeTelemetryText(candidate.stack, MAX_STACK_LENGTH) - : undefined - return { - name: sanitizeName(candidate.name, 'Error'), - message: sanitizeTelemetryText( - typeof candidate.message === 'string' && candidate.message.length > 0 - ? candidate.message - : 'Unknown error' - ), - ...(code !== undefined ? { code } : {}), - ...(stack !== undefined ? { stack } : {}) - } + if (error instanceof Error || typeof error === 'object') { + return sanitizeErrorCandidate(error as ErrorCandidate, includeStack) } return { name: 'Error', @@ -553,12 +545,9 @@ export class Telemetry { activeContext(): TelemetrySpanContext | undefined { try { - return ( - this.config.contextManager?.active() ?? - synchronousContextStack[synchronousContextStack.length - 1] - ) + return this.config.contextManager?.active() ?? synchronousContextStack.at(-1) } catch { - return synchronousContextStack[synchronousContextStack.length - 1] + return synchronousContextStack.at(-1) } } From 05a0130588b7d339480a9a69b27c9289ed93a414 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 29 Jul 2026 18:28:02 -0700 Subject: [PATCH 3/9] style: format auth telemetry middleware --- packages/middleware/auth-express-middleware/src/index.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/middleware/auth-express-middleware/src/index.ts b/packages/middleware/auth-express-middleware/src/index.ts index d49190f7e..5ef6f8162 100644 --- a/packages/middleware/auth-express-middleware/src/index.ts +++ b/packages/middleware/auth-express-middleware/src/index.ts @@ -43,11 +43,7 @@ function parseTraceparent( ): { traceId: string; spanId: string; traceFlags: number } | undefined { if (typeof value !== 'string' || value.length > 128) return undefined const match = TRACEPARENT_PATTERN.exec(value.trim()) - if ( - match == null || - /^0{32}$/.test(match[1]) || - /^0{16}$/.test(match[2]) - ) { + if (match == null || /^0{32}$/.test(match[1]) || /^0{16}$/.test(match[2])) { return undefined } return { From 28648c656cd07492c3bf75203527a330423d5bb5 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 29 Jul 2026 18:49:00 -0700 Subject: [PATCH 4/9] perf: reduce wallet platform bundle size --- .../src/CWIStyleWalletManager.ts | 2 +- .../src/storage/portable/index.ts | 2 +- .../src/types/hash-wasm-subpaths.d.ts | 35 +++++++++++++++++++ .../wallet-toolbox/src/utility/hashWasm.ts | 9 +++++ scripts/check-wallet-toolbox-platform.mjs | 1 + 5 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/types/hash-wasm-subpaths.d.ts create mode 100644 packages/wallet/wallet-toolbox/src/utility/hashWasm.ts diff --git a/packages/wallet/wallet-toolbox/src/CWIStyleWalletManager.ts b/packages/wallet/wallet-toolbox/src/CWIStyleWalletManager.ts index 3b6b38d05..4037d3908 100644 --- a/packages/wallet/wallet-toolbox/src/CWIStyleWalletManager.ts +++ b/packages/wallet/wallet-toolbox/src/CWIStyleWalletManager.ts @@ -67,8 +67,8 @@ import { Telemetry, TelemetryConfig } from '@bsv/sdk' -import { argon2id, pbkdf2, createSHA256, createSHA512 } from 'hash-wasm' import { PrivilegedKeyManager } from './sdk/PrivilegedKeyManager' +import { argon2id, createSHA256, createSHA512, pbkdf2 } from './utility/hashWasm' /** * Number of rounds used in PBKDF2 for deriving password keys. diff --git a/packages/wallet/wallet-toolbox/src/storage/portable/index.ts b/packages/wallet/wallet-toolbox/src/storage/portable/index.ts index 785186b43..9658cc6c1 100644 --- a/packages/wallet/wallet-toolbox/src/storage/portable/index.ts +++ b/packages/wallet/wallet-toolbox/src/storage/portable/index.ts @@ -4,7 +4,7 @@ import { Random, Utils } from '@bsv/sdk' import { AESGCM, AESGCMDecrypt } from '@bsv/sdk/primitives/AESGCM' -import { argon2id } from 'hash-wasm' +import { argon2id } from '../../utility/hashWasm' import { StorageProvider } from '../StorageProvider' import { TableCertificate, diff --git a/packages/wallet/wallet-toolbox/src/types/hash-wasm-subpaths.d.ts b/packages/wallet/wallet-toolbox/src/types/hash-wasm-subpaths.d.ts new file mode 100644 index 000000000..b14a98668 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/types/hash-wasm-subpaths.d.ts @@ -0,0 +1,35 @@ +declare module 'hash-wasm/dist/argon2.umd.min.js' { + import { argon2id } from 'hash-wasm' + + const api: { + argon2id: typeof argon2id + } + export = api +} + +declare module 'hash-wasm/dist/pbkdf2.umd.min.js' { + import { pbkdf2 } from 'hash-wasm' + + const api: { + pbkdf2: typeof pbkdf2 + } + export = api +} + +declare module 'hash-wasm/dist/sha256.umd.min.js' { + import { createSHA256 } from 'hash-wasm' + + const api: { + createSHA256: typeof createSHA256 + } + export = api +} + +declare module 'hash-wasm/dist/sha512.umd.min.js' { + import { createSHA512 } from 'hash-wasm' + + const api: { + createSHA512: typeof createSHA512 + } + export = api +} diff --git a/packages/wallet/wallet-toolbox/src/utility/hashWasm.ts b/packages/wallet/wallet-toolbox/src/utility/hashWasm.ts new file mode 100644 index 000000000..22b9461aa --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/utility/hashWasm.ts @@ -0,0 +1,9 @@ +import argon2Api from 'hash-wasm/dist/argon2.umd.min.js' +import pbkdf2Api from 'hash-wasm/dist/pbkdf2.umd.min.js' +import sha256Api from 'hash-wasm/dist/sha256.umd.min.js' +import sha512Api from 'hash-wasm/dist/sha512.umd.min.js' + +export const argon2id = argon2Api.argon2id +export const pbkdf2 = pbkdf2Api.pbkdf2 +export const createSHA256 = sha256Api.createSHA256 +export const createSHA512 = sha512Api.createSHA512 diff --git a/scripts/check-wallet-toolbox-platform.mjs b/scripts/check-wallet-toolbox-platform.mjs index 041a09cab..539b28654 100644 --- a/scripts/check-wallet-toolbox-platform.mjs +++ b/scripts/check-wallet-toolbox-platform.mjs @@ -65,6 +65,7 @@ const prohibitedModulePatterns = [ /(?:^|[/\\])body-parser(?:[/\\]|$)/, /(?:^|[/\\])dotenv(?:[/\\]|$)/, /(?:^|[/\\])express(?:[/\\]|$)/, + /(?:^|[/\\])hash-wasm[/\\]dist[/\\]index\.umd\.js$/, /(?:^|[/\\])knex(?:[/\\]|$)/, /(?:^|[/\\])mysql2(?:[/\\]|$)/, /(?:^|[/\\])ws(?:[/\\]|$)/, From 1b0b304c55fbe22afad7ae5bec0059bd5de7683a Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 29 Jul 2026 19:20:49 -0700 Subject: [PATCH 5/9] test: harden telemetry patch coverage --- .../__tests/ExpressTransportHardening.test.ts | 96 +++++ .../src/telemetry/__tests__/Telemetry.test.ts | 71 ++++ .../src/__tests__/Wallet.telemetry.test.ts | 73 ++++ .../__tests/Services.telemetry.test.ts | 70 ++++ .../__tests__/signingTelemetry.test.ts | 57 +++ .../wallet-toolbox/src/storage/StorageKnex.ts | 393 ++++++++---------- .../src/storage/StorageReader.ts | 98 ++--- .../__test/StorageKnex.telemetry.test.ts | 53 +++ .../src/storage/remoting/StorageClient.ts | 15 +- .../src/storage/remoting/StorageServer.ts | 288 +++++++------ .../__test/StorageClient.telemetry.test.ts | 70 ++++ .../remoting/__test/StorageServerRpc.test.ts | 98 ++++- 12 files changed, 980 insertions(+), 402 deletions(-) create mode 100644 packages/wallet/wallet-toolbox/src/__tests__/Wallet.telemetry.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/services/__tests/Services.telemetry.test.ts create mode 100644 packages/wallet/wallet-toolbox/src/signer/methods/__tests__/signingTelemetry.test.ts diff --git a/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts b/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts index 6290857f9..0d64bc135 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts @@ -787,4 +787,100 @@ describe('ExpressTransport hardening', () => { expect(JSON.stringify(events)).not.toContain('customer-record') expect(JSON.stringify(events)).not.toContain('never-report-this') }) + + it('reports middleware errors and response completion without changing next semantics', async () => { + const events: any[] = [] + const telemetry = { + sink: { capture: (event: unknown) => events.push(event) }, + traceIdFactory: () => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanIdFactory: () => 'bbbbbbbbbbbbbbbb' + } + const handleIncomingRequest = jest.spyOn(ExpressTransport.prototype, 'handleIncomingRequest') + + handleIncomingRequest.mockImplementationOnce(async (_req, _res, next) => { + next(new Error('continued with error')) + }) + const continued = createAuthMiddleware({ + wallet: new MockWallet(new PrivateKey(1)), + allowUnauthenticated: true, + telemetry + }) + const continuedNext = jest.fn() + continued( + { + path: '/public', + method: 'GET', + headers: { + traceparent: `00-${'0'.repeat(32)}-${'0'.repeat(16)}-01` + } + } as any, + responseMock(), + continuedNext + ) + await flushPromises() + expect(continuedNext).toHaveBeenCalledWith(expect.any(Error)) + expect(events.at(-1)).toMatchObject({ + spanStatus: 'error', + attributes: { 'auth.disposition': 'continued' } + }) + + handleIncomingRequest.mockRejectedValueOnce(new Error('transport failed')) + const rejected = createAuthMiddleware({ + wallet: new MockWallet(new PrivateKey(1)), + allowUnauthenticated: true, + telemetry + }) + const rejectedNext = jest.fn() + rejected( + { + path: '/public', + method: 'GET', + headers: { traceparent: 'not-a-traceparent' } + } as any, + responseMock(), + rejectedNext + ) + await flushPromises() + expect(rejectedNext).toHaveBeenCalledWith(expect.any(Error)) + expect(events.at(-1)).toMatchObject({ + spanStatus: 'error', + attributes: { 'auth.disposition': 'middleware_error' } + }) + + handleIncomingRequest.mockReturnValue(new Promise(() => {})) + const pending = createAuthMiddleware({ + wallet: new MockWallet(new PrivateKey(1)), + allowUnauthenticated: true, + telemetry + }) + for (const [statusCode, writableEnded, eventName, status] of [ + [503, true, 'finish', 'error'], + [200, false, 'close', 'cancelled'] + ] as const) { + const res = responseMock() + res.statusCode = statusCode + res.writableEnded = writableEnded + pending( + { + path: '/public', + method: 'GET', + headers: {} + } as any, + res, + jest.fn() + ) + const callback = res.once.mock.calls.find(([name]: [string]) => name === eventName)?.[1] + callback() + callback() + expect(events.at(-1)).toMatchObject({ + spanStatus: status, + attributes: { + 'auth.disposition': eventName === 'finish' ? 'responded' : 'connection_closed', + 'http.response.status_code': statusCode + } + }) + } + + handleIncomingRequest.mockRestore() + }) }) diff --git a/packages/sdk/src/telemetry/__tests__/Telemetry.test.ts b/packages/sdk/src/telemetry/__tests__/Telemetry.test.ts index 04469c8ba..1dbd81712 100644 --- a/packages/sdk/src/telemetry/__tests__/Telemetry.test.ts +++ b/packages/sdk/src/telemetry/__tests__/Telemetry.test.ts @@ -330,4 +330,75 @@ describe('Telemetry', () => { expect(captured[0].traceId).toBeUndefined() expect(captured[0].durationMs).toBeUndefined() }) + + it('keeps every defensive tracing fallback isolated from application work', () => { + const captured: TelemetryEvent[] = [] + const telemetry = new Telemetry({ + sink: { + capture: event => { + captured.push(event) + } + }, + minimumSeverity: 'debug', + correlationIdFactory: () => { + throw new Error('correlation id unavailable') + }, + traceIdFactory: () => { + throw new Error('trace id unavailable') + }, + spanIdFactory: () => { + throw new Error('span id unavailable') + }, + contextManager: { + active: () => { + throw new Error('context unavailable') + }, + run: (_context, callback) => callback() + }, + runtimeMetrics: { + snapshot: () => { + throw new Error('runtime snapshot unavailable') + }, + diff: () => { + throw new Error('runtime diff unavailable') + } + } + }) + + expect(telemetry.createCorrelationId()).toMatch(/^[a-z0-9]+-[a-z0-9]+$/) + expect(telemetry.createTraceId()).toMatch(/^[0-9a-f]{32}$/) + expect(telemetry.createSpanId()).toMatch(/^[0-9a-f]{16}$/) + expect(telemetry.activeContext()).toBeUndefined() + expect(telemetry.runtimeSnapshot()).toBeUndefined() + expect(telemetry.runtimeDiff(undefined, undefined)).toBeUndefined() + + const unbound = {} + const target = {} + telemetry.linkContext(undefined, target) + telemetry.linkContext(unbound, target) + expect(telemetry.contextFor(target)).toBeUndefined() + + const span = telemetry.startSpan('defensive', { component: 'test' }) + telemetry.bindContext(unbound, span.context) + telemetry.linkContext(unbound, target) + expect(telemetry.contextFor(target)).toEqual(span.context) + span.capture('defensive.event', { disposition: 'observed' }, 'debug') + span.end({ status: 'cancelled' }) + span.end({ status: 'error' }) + + expect(captured.map(event => event.name)).toEqual(['defensive.event', 'defensive']) + expect(captured[1]).toMatchObject({ spanStatus: 'cancelled' }) + expect(captured[1]).not.toHaveProperty('attributes') + + expect(() => + telemetry.withSpan('sync.failure', { component: 'test' }, () => { + throw new Error('synchronous failure') + }) + ).toThrow('synchronous failure') + expect(captured.at(-1)).toMatchObject({ + name: 'sync.failure', + spanStatus: 'error', + error: { message: 'synchronous failure' } + }) + }) }) diff --git a/packages/wallet/wallet-toolbox/src/__tests__/Wallet.telemetry.test.ts b/packages/wallet/wallet-toolbox/src/__tests__/Wallet.telemetry.test.ts new file mode 100644 index 000000000..120601601 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/__tests__/Wallet.telemetry.test.ts @@ -0,0 +1,73 @@ +import { Telemetry, TelemetryEvent } from '@bsv/sdk' +import { Wallet } from '../Wallet' + +describe('Wallet validation telemetry', () => { + function walletWithTelemetry(telemetry: Telemetry): Wallet { + const wallet = Object.create(Wallet.prototype) as Wallet + Reflect.set(wallet, 'identityKey', 'wallet-identity') + Reflect.set(wallet, 'telemetry', telemetry) + return wallet + } + + it('binds normalized arguments to a validation span and reports rejection', () => { + const events: TelemetryEvent[] = [] + const telemetry = new Telemetry({ + sink: { capture: event => events.push(event) }, + traceIdFactory: () => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanIdFactory: () => 'bbbbbbbbbbbbbbbb' + }) + const wallet = walletWithTelemetry(telemetry) + const args = { limit: 10 } + const normalized = { limit: 10, offset: 0 } + const validate = jest.fn(() => normalized) + + const result = Reflect.get(wallet, 'validateAuthAndArgs').call( + wallet, + args, + validate + ) + + expect(result).toEqual({ + vargs: normalized, + auth: { identityKey: 'wallet-identity' } + }) + expect(telemetry.contextFor(normalized)).toEqual({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanId: 'bbbbbbbbbbbbbbbb' + }) + expect(events[0]).toMatchObject({ + name: 'wallet.validate_args', + spanStatus: 'ok', + attributes: { 'validation.result': 'ok' } + }) + + expect(() => + Reflect.get(wallet, 'validateAuthAndArgs').call( + wallet, + { invalid: true }, + () => { + throw new Error('invalid arguments') + } + ) + ).toThrow('invalid arguments') + expect(events[1]).toMatchObject({ + name: 'wallet.validate_args', + spanStatus: 'error', + attributes: { 'validation.result': 'rejected' }, + error: { message: 'invalid arguments' } + }) + }) + + it('keeps the disabled and non-object validation path allocation-free', () => { + const validate = jest.fn(() => 'validated') + const disabled = walletWithTelemetry(new Telemetry()) + + expect( + Reflect.get(disabled, 'validateAuthAndArgs').call(disabled, null, validate) + ).toEqual({ + vargs: 'validated', + auth: { identityKey: 'wallet-identity' } + }) + expect(validate).toHaveBeenCalledWith(null, undefined) + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/services/__tests/Services.telemetry.test.ts b/packages/wallet/wallet-toolbox/src/services/__tests/Services.telemetry.test.ts new file mode 100644 index 000000000..dc82bff12 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/services/__tests/Services.telemetry.test.ts @@ -0,0 +1,70 @@ +import type { TelemetryEvent } from '@bsv/sdk' +import { Services } from '../Services' + +const header = { + version: 1, + previousHash: '00'.repeat(32), + merkleRoot: '11'.repeat(32), + time: 1, + bits: 1, + nonce: 1, + height: 123, + hash: '22'.repeat(32) +} + +describe('Services telemetry', () => { + it('correlates ChainTracks retries and public service operations', async () => { + const events: TelemetryEvent[] = [] + const options = Services.createDefaultOptions('test') + options.chaintracks = { + currentHeight: jest.fn(async () => 123), + findHeaderForHeight: jest.fn(async () => header), + findHeaderForBlockHash: jest.fn(async () => header) + } as any + options.telemetry = { + sink: { capture: event => events.push(event) }, + traceIdFactory: () => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + } + const services = new Services(options) + + let attempts = 0 + await expect( + services.invokeChaintracksWithRetry(async () => { + attempts++ + if (attempts < 3) { + throw Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }) + } + return 'recovered' + }, 'retry_test') + ).resolves.toBe('recovered') + await expect(services.getHeight()).resolves.toBe(123) + await expect(services.getHeaderForHeight(123)).resolves.toHaveLength(80) + await expect(services.hashToHeader(header.hash)).resolves.toEqual(header) + + const requests = events.filter(event => event.name === 'wallet.chaintracks.request') + const attemptsSpans = events.filter(event => event.name === 'wallet.chaintracks.attempt') + expect(requests).toHaveLength(4) + expect(attemptsSpans).toHaveLength(6) + expect(attemptsSpans.every(event => + requests.some(request => + request.traceId === event.traceId && request.spanId === event.parentSpanId + ) + )).toBe(true) + expect(requests.map(event => event.attributes?.['chaintracks.operation'])).toEqual([ + 'retry_test', + 'current_height', + 'find_header_for_height', + 'find_header_for_hash' + ]) + }) + + it('retains the zero-overhead path when telemetry is disabled', async () => { + const options = Services.createDefaultOptions('test') + options.chaintracks = {} as any + const services = new Services(options) + + await expect( + services.invokeChaintracksWithRetry(async () => 'untraced') + ).resolves.toBe('untraced') + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/signingTelemetry.test.ts b/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/signingTelemetry.test.ts new file mode 100644 index 000000000..419c4de21 --- /dev/null +++ b/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/signingTelemetry.test.ts @@ -0,0 +1,57 @@ +import { Telemetry, TelemetryEvent } from '@bsv/sdk' +import { completeSignedTransaction } from '../completeSignedTransaction' +import { signAction } from '../signAction' + +describe('signing telemetry', () => { + it('reports an invalid signAction reference on the correlated root span', async () => { + const events: TelemetryEvent[] = [] + const wallet = { + pendingSignActions: {}, + telemetry: new Telemetry({ + sink: { capture: event => events.push(event) }, + traceIdFactory: () => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanIdFactory: () => 'bbbbbbbbbbbbbbbb' + }) + } as any + + await expect( + signAction(wallet, { identityKey: 'wallet' }, { reference: 'missing' }) + ).rejects.toThrow('recovery of out-of-session signAction') + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + name: 'wallet.sign_action', + spanStatus: 'error' + }) + + wallet.telemetry = new Telemetry() + await expect( + signAction(wallet, { identityKey: 'wallet' }, { reference: 'missing' }) + ).rejects.toThrow('recovery of out-of-session signAction') + }) + + it('times transaction signing while retaining the disabled fast path', async () => { + const events: TelemetryEvent[] = [] + const sign = jest.fn(async () => undefined) + const prior = { + args: { inputs: [] }, + pdi: [], + tx: { inputs: [], sign } + } as any + const wallet = { + telemetry: new Telemetry({ + sink: { capture: event => events.push(event) } + }) + } as any + + await expect(completeSignedTransaction(prior, {}, wallet)).resolves.toBe(prior.tx) + expect(events[0]).toMatchObject({ + name: 'wallet.crypto.transaction_sign', + spanStatus: 'ok', + attributes: { 'crypto.input_count': 0 } + }) + + wallet.telemetry = new Telemetry() + await expect(completeSignedTransaction(prior, {}, wallet)).resolves.toBe(prior.tx) + expect(sign).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts index 836739528..fff93a762 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts @@ -102,7 +102,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide this.endQuerySpan(query, error) } - constructor(options: StorageKnexOptions) { + constructor (options: StorageKnexOptions) { super(options) if (options.knex == null) throw new WERR_INVALID_PARAMETER('options.knex', 'valid') this.knex = options.knex @@ -113,7 +113,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide } } - private databaseSystem(): string { + private databaseSystem (): string { const clientName = (this.knex.client as { config?: { client?: string } }).config?.client ?? '' if (clientName.includes('sqlite')) return 'sqlite' if (clientName.includes('mysql')) return 'mysql' @@ -121,7 +121,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return 'unknown' } - private endQuerySpan(query: KnexTelemetryQuery, error?: unknown): void { + private endQuerySpan (query: KnexTelemetryQuery, error?: unknown): void { if (query.__knexQueryUid == null) return const span = this.querySpans.get(query.__knexQueryUid) if (span == null) return @@ -129,15 +129,13 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide span.end(error == null ? {} : { status: 'error', error }) } - protected override supportsActionBatchPersistence(): boolean { - return true - } + protected override supportsActionBatchPersistence (): boolean { return true } - async readSettings(): Promise { + async readSettings (): Promise { return this.validateEntity(verifyOne(await this.toDb()('settings'))) } - override async getProvenOrRawTx(txid: string, trx?: TrxToken): Promise { + override async getProvenOrRawTx (txid: string, trx?: TrxToken): Promise { const k = this.toDb(trx) const r: ProvenOrRawTx = { proven: undefined, @@ -161,22 +159,17 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return r } - dbTypeSubstring(source: string, fromOffset: number, forLength?: number): string { + dbTypeSubstring (source: string, fromOffset: number, forLength?: number): string { if (this.dbtype === 'MySQL') return `substring(${source} from ${fromOffset} for ${String(forLength)})` return `substr(${source}, ${fromOffset}, ${String(forLength)})` } - private normaliseKnexRawResult(rs: unknown): Array<{ rawTx: Buffer | null }> { + private normaliseKnexRawResult (rs: unknown): Array<{ rawTx: Buffer | null }> { if (this.dbtype === 'MySQL') return (rs as Array>)[0] return rs as Array<{ rawTx: Buffer | null }> } - private async getRawTxSlice( - txid: string, - offset: number, - length: number, - trx?: TrxToken - ): Promise { + private async getRawTxSlice (txid: string, offset: number, length: number, trx?: TrxToken): Promise { const sub = this.dbTypeSubstring('rawTx', offset + 1, length) let rs = await this.toDb(trx).raw(`select ${sub} as rawTx from proven_txs where txid = '${txid}'`) const proven = verifyOneOrNone(this.normaliseKnexRawResult(rs)) @@ -188,7 +181,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return req?.rawTx != null ? Array.from(req.rawTx) : undefined } - override async getRawTxOfKnownValidTransaction( + override async getRawTxOfKnownValidTransaction ( txid?: string, offset?: number, length?: number, @@ -203,7 +196,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return r.proven != null ? r.proven.rawTx : r.rawTx } - getProvenTxsForUserQuery(args: FindForUserSincePagedArgs): Knex.QueryBuilder { + getProvenTxsForUserQuery (args: FindForUserSincePagedArgs): Knex.QueryBuilder { const k = this.toDb(args.trx) let q = k('proven_txs').where(function () { void this.whereExists( @@ -221,13 +214,13 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return q } - override async getProvenTxsForUser(args: FindForUserSincePagedArgs): Promise { + override async getProvenTxsForUser (args: FindForUserSincePagedArgs): Promise { const q = this.getProvenTxsForUserQuery(args) const rs = await q return this.validateEntities(rs) } - getProvenTxReqsForUserQuery(args: FindForUserSincePagedArgs): Knex.QueryBuilder { + getProvenTxReqsForUserQuery (args: FindForUserSincePagedArgs): Knex.QueryBuilder { const k = this.toDb(args.trx) let q = k('proven_tx_reqs').where(function () { void this.whereExists( @@ -245,13 +238,13 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return q } - override async getProvenTxReqsForUser(args: FindForUserSincePagedArgs): Promise { + override async getProvenTxReqsForUser (args: FindForUserSincePagedArgs): Promise { const q = this.getProvenTxReqsForUserQuery(args) const rs = await q return this.validateEntities(rs, undefined, ['notified']) } - getTxLabelMapsForUserQuery(args: FindForUserSincePagedArgs): Knex.QueryBuilder { + getTxLabelMapsForUserQuery (args: FindForUserSincePagedArgs): Knex.QueryBuilder { const k = this.toDb(args.trx) let q = k('tx_labels_map').whereExists( k @@ -267,13 +260,13 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return q } - override async getTxLabelMapsForUser(args: FindForUserSincePagedArgs): Promise { + override async getTxLabelMapsForUser (args: FindForUserSincePagedArgs): Promise { const q = this.getTxLabelMapsForUserQuery(args) const rs = await q return this.validateEntities(rs, undefined, ['isDeleted']) } - getOutputTagMapsForUserQuery(args: FindForUserSincePagedArgs): Knex.QueryBuilder { + getOutputTagMapsForUserQuery (args: FindForUserSincePagedArgs): Knex.QueryBuilder { const k = this.toDb(args.trx) let q = k('output_tags_map').whereExists( k @@ -289,23 +282,23 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return q } - override async getOutputTagMapsForUser(args: FindForUserSincePagedArgs): Promise { + override async getOutputTagMapsForUser (args: FindForUserSincePagedArgs): Promise { const q = this.getOutputTagMapsForUserQuery(args) const rs = await q return this.validateEntities(rs, undefined, ['isDeleted']) } - override async listActions(auth: AuthId, vargs: Validation.ValidListActionsArgs): Promise { + override async listActions (auth: AuthId, vargs: Validation.ValidListActionsArgs): Promise { if (auth.userId == null) throw new WERR_UNAUTHORIZED() return await listActions(this, auth, vargs) } - override async listOutputs(auth: AuthId, vargs: Validation.ValidListOutputsArgs): Promise { + override async listOutputs (auth: AuthId, vargs: Validation.ValidListOutputsArgs): Promise { if (auth.userId == null) throw new WERR_UNAUTHORIZED() return await listOutputs(this, auth, vargs) } - override async insertProvenTx(tx: TableProvenTx, trx?: TrxToken): Promise { + override async insertProvenTx (tx: TableProvenTx, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(tx, trx) if (e.provenTxId === 0) delete e.provenTxId const [id] = await this.toDb(trx)('proven_txs').insert(e) @@ -313,7 +306,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return tx.provenTxId } - override async insertActionBatch(batch: TableActionBatch, trx?: TrxToken): Promise { + override async insertActionBatch (batch: TableActionBatch, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(batch, trx, ['expiresAt', 'hardExpiresAt']) if (e.actionBatchId === 0) delete e.actionBatchId const [id] = await this.toDb(trx)('action_batches').insert(e) @@ -321,7 +314,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return id } - override async findActionBatch( + override async findActionBatch ( userId: number, batchId: string, trx?: TrxToken @@ -330,16 +323,19 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return row == null ? undefined : this.validateEntity(row, ['expiresAt', 'hardExpiresAt']) } - override async findActionBatchForUpdate( + override async findActionBatchForUpdate ( userId: number, batchId: string, trx: TrxToken ): Promise { - const row = await this.toDb(trx)('action_batches').where({ userId, batchId }).forUpdate().first() + const row = await this.toDb(trx)('action_batches') + .where({ userId, batchId }) + .forUpdate() + .first() return row == null ? undefined : this.validateEntity(row, ['expiresAt', 'hardExpiresAt']) } - override async findExpiredActionBatches(now: Date, trx?: TrxToken): Promise { + override async findExpiredActionBatches (now: Date, trx?: TrxToken): Promise { const rows = await this.toDb(trx)('action_batches') .whereIn('status', ['active', 'prepared']) .andWhere(builder => { @@ -350,7 +346,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntities(rows, ['expiresAt', 'hardExpiresAt']) } - override async updateActionBatch( + override async updateActionBatch ( actionBatchId: number, update: Partial, trx?: TrxToken @@ -360,24 +356,27 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .update(this.validatePartialForUpdate(update, ['expiresAt', 'hardExpiresAt'])) } - override async deleteActionBatch(actionBatchId: number, trx?: TrxToken): Promise { + override async deleteActionBatch (actionBatchId: number, trx?: TrxToken): Promise { await this.toDb(trx)('action_batches').where({ actionBatchId }).delete() } - override async reserveActionBatchOutputs(reservations: TableActionBatchOutput[], trx?: TrxToken): Promise { + override async reserveActionBatchOutputs ( + reservations: TableActionBatchOutput[], + trx?: TrxToken + ): Promise { if (reservations.length === 0) return const rows = await Promise.all(reservations.map(async r => await this.validateEntityForInsert(r, trx))) await this.toDb(trx)('action_batch_outputs').insert(rows) } - override async findActionBatchOutputIds(actionBatchId: number, trx?: TrxToken): Promise { + override async findActionBatchOutputIds (actionBatchId: number, trx?: TrxToken): Promise { const rows = await this.toDb(trx)('action_batch_outputs') .where({ actionBatchId }) .select('outputId') return rows.map(r => r.outputId) } - override async findReservedActionBatchOutputIds(outputIds: number[], trx?: TrxToken): Promise { + override async findReservedActionBatchOutputIds (outputIds: number[], trx?: TrxToken): Promise { if (outputIds.length === 0) return [] const rows = await this.toDb(trx)('action_batch_outputs') .whereIn('outputId', outputIds) @@ -385,11 +384,11 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return rows.map(r => r.outputId) } - override async deleteActionBatchOutputReservations(actionBatchId: number, trx?: TrxToken): Promise { + override async deleteActionBatchOutputReservations (actionBatchId: number, trx?: TrxToken): Promise { await this.toDb(trx)('action_batch_outputs').where({ actionBatchId }).delete() } - override async putActionBatchBlobRecord(blob: TableActionBatchBlob, trx?: TrxToken): Promise { + override async putActionBatchBlobRecord (blob: TableActionBatchBlob, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(blob, trx) if (e.actionBatchBlobId === 0) delete e.actionBatchBlobId await this.toDb(trx)('action_batch_blobs') @@ -398,7 +397,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .ignore() } - override async findActionBatchBlobRecord( + override async findActionBatchBlobRecord ( actionBatchId: number, digest: string, trx?: TrxToken @@ -414,7 +413,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntity(row) } - override async findActionBatchBlobRecords( + override async findActionBatchBlobRecords ( actionBatchId: number, digests: string[], trx?: TrxToken @@ -423,11 +422,9 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide const rows: TableActionBatchBlob[] = [] const uniqueDigests = [...new Set(digests)] for (let offset = 0; offset < uniqueDigests.length; offset += ACTION_BATCH_BLOB_SQL_CHUNK) { - rows.push( - ...(await this.toDb(trx)('action_batch_blobs') - .where({ actionBatchId }) - .whereIn('digest', uniqueDigests.slice(offset, offset + ACTION_BATCH_BLOB_SQL_CHUNK))) - ) + rows.push(...await this.toDb(trx)('action_batch_blobs') + .where({ actionBatchId }) + .whereIn('digest', uniqueDigests.slice(offset, offset + ACTION_BATCH_BLOB_SQL_CHUNK))) } for (const row of rows) { if (Buffer.isBuffer(row.bytes)) { @@ -438,15 +435,16 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntities(rows) } - override async putActionBatchBlobRecords(blobs: TableActionBatchBlob[], trx?: TrxToken): Promise { + override async putActionBatchBlobRecords ( + blobs: TableActionBatchBlob[], + trx?: TrxToken + ): Promise { if (blobs.length === 0) return - const rows = await Promise.all( - blobs.map(async blob => { - const row = await this.validateEntityForInsert(blob, trx) - if (row.actionBatchBlobId === 0) delete row.actionBatchBlobId - return row - }) - ) + const rows = await Promise.all(blobs.map(async blob => { + const row = await this.validateEntityForInsert(blob, trx) + if (row.actionBatchBlobId === 0) delete row.actionBatchBlobId + return row + })) for (let offset = 0; offset < rows.length; offset += ACTION_BATCH_BLOB_SQL_CHUNK) { await this.toDb(trx)('action_batch_blobs') .insert(rows.slice(offset, offset + ACTION_BATCH_BLOB_SQL_CHUNK)) @@ -455,11 +453,11 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide } } - override async deleteActionBatchBlobRecords(actionBatchId: number, trx?: TrxToken): Promise { + override async deleteActionBatchBlobRecords (actionBatchId: number, trx?: TrxToken): Promise { await this.toDb(trx)('action_batch_blobs').where({ actionBatchId }).delete() } - override async insertProvenTxReq(tx: TableProvenTxReq, trx?: TrxToken): Promise { + override async insertProvenTxReq (tx: TableProvenTxReq, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(tx, trx) if (e.provenTxReqId === 0) delete e.provenTxReqId const [id] = await this.toDb(trx)('proven_tx_reqs').insert(e) @@ -467,7 +465,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return tx.provenTxReqId } - override async insertUser(user: TableUser, trx?: TrxToken): Promise { + override async insertUser (user: TableUser, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(user, trx) if (e.userId === 0) delete e.userId const [id] = await this.toDb(trx)('users').insert(e) @@ -475,17 +473,13 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return user.userId } - override async insertCertificateAuth(auth: AuthId, certificate: TableCertificateX): Promise { - if ( - auth.userId == null || - (certificate.userId != null && certificate.userId !== 0 && certificate.userId !== auth.userId) - ) - throw new WERR_UNAUTHORIZED() + override async insertCertificateAuth (auth: AuthId, certificate: TableCertificateX): Promise { + if (auth.userId == null || (certificate.userId != null && certificate.userId !== 0 && certificate.userId !== auth.userId)) throw new WERR_UNAUTHORIZED() certificate.userId = auth.userId return await this.insertCertificate(certificate) } - override async insertCertificate(certificate: TableCertificateX, trx?: TrxToken): Promise { + override async insertCertificate (certificate: TableCertificateX, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(certificate, trx, undefined, ['isDeleted']) if (e.certificateId === 0) delete e.certificateId @@ -508,12 +502,12 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return certificate.certificateId } - override async insertCertificateField(certificateField: TableCertificateField, trx?: TrxToken): Promise { + override async insertCertificateField (certificateField: TableCertificateField, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(certificateField, trx) await this.toDb(trx)('certificate_fields').insert(e) } - override async insertOutputBasket(basket: TableOutputBasket, trx?: TrxToken): Promise { + override async insertOutputBasket (basket: TableOutputBasket, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(basket, trx, undefined, ['isDeleted']) if (e.basketId === 0) delete e.basketId const [id] = await this.toDb(trx)('output_baskets').insert(e) @@ -521,7 +515,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return basket.basketId } - override async insertTransaction(tx: TableTransaction, trx?: TrxToken): Promise { + override async insertTransaction (tx: TableTransaction, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(tx, trx) if (e.transactionId === 0) delete e.transactionId const [id] = await this.toDb(trx)('transactions').insert(e) @@ -529,7 +523,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return tx.transactionId } - override async insertCommission(commission: TableCommission, trx?: TrxToken): Promise { + override async insertCommission (commission: TableCommission, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(commission, trx) if (e.commissionId === 0) delete e.commissionId const [id] = await this.toDb(trx)('commissions').insert(e) @@ -537,7 +531,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return commission.commissionId } - override async insertOutput(output: TableOutput, trx?: TrxToken): Promise { + override async insertOutput (output: TableOutput, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(output, trx) if (e.outputId === 0) delete e.outputId const [id] = await this.toDb(trx)('outputs').insert(e) @@ -545,7 +539,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return output.outputId } - override async insertOutputTag(tag: TableOutputTag, trx?: TrxToken): Promise { + override async insertOutputTag (tag: TableOutputTag, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(tag, trx, undefined, ['isDeleted']) if (e.outputTagId === 0) delete e.outputTagId const [id] = await this.toDb(trx)('output_tags').insert(e) @@ -553,12 +547,12 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return tag.outputTagId } - override async insertOutputTagMap(tagMap: TableOutputTagMap, trx?: TrxToken): Promise { + override async insertOutputTagMap (tagMap: TableOutputTagMap, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(tagMap, trx, undefined, ['isDeleted']) await this.toDb(trx)('output_tags_map').insert(e) } - override async insertTxLabel(label: TableTxLabel, trx?: TrxToken): Promise { + override async insertTxLabel (label: TableTxLabel, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(label, trx, undefined, ['isDeleted']) if (e.txLabelId === 0) delete e.txLabelId const [id] = await this.toDb(trx)('tx_labels').insert(e) @@ -566,12 +560,12 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return label.txLabelId } - override async insertTxLabelMap(labelMap: TableTxLabelMap, trx?: TrxToken): Promise { + override async insertTxLabelMap (labelMap: TableTxLabelMap, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(labelMap, trx, undefined, ['isDeleted']) await this.toDb(trx)('tx_labels_map').insert(e) } - override async insertMonitorEvent(event: TableMonitorEvent, trx?: TrxToken): Promise { + override async insertMonitorEvent (event: TableMonitorEvent, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(event, trx) if (e.id === 0) delete e.id const [id] = await this.toDb(trx)('monitor_events').insert(e) @@ -579,7 +573,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return event.id } - override async insertSyncState(syncState: TableSyncState, trx?: TrxToken): Promise { + override async insertSyncState (syncState: TableSyncState, trx?: TrxToken): Promise { const e = await this.validateEntityForInsert(syncState, trx, ['when'], ['init']) if (e.syncStateId === 0) delete e.syncStateId const [id] = await this.toDb(trx)('sync_states').insert(e) @@ -587,7 +581,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return syncState.syncStateId } - override async updateCertificateField( + override async updateCertificateField ( certificateId: number, fieldName: string, update: Partial, @@ -599,35 +593,35 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .update(this.validatePartialForUpdate(update)) } - override async updateCertificate(id: number, update: Partial, trx?: TrxToken): Promise { + override async updateCertificate (id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('certificates') .where({ certificateId: id }) .update(this.validatePartialForUpdate(update, undefined, ['isDeleted'])) } - override async updateCommission(id: number, update: Partial, trx?: TrxToken): Promise { + override async updateCommission (id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('commissions') .where({ commissionId: id }) .update(this.validatePartialForUpdate(update)) } - override async updateOutputBasket(id: number, update: Partial, trx?: TrxToken): Promise { + override async updateOutputBasket (id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('output_baskets') .where({ basketId: id }) .update(this.validatePartialForUpdate(update, undefined, ['isDeleted'])) } - override async updateOutput(id: number, update: Partial, trx?: TrxToken): Promise { + override async updateOutput (id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('outputs') .where({ outputId: id }) .update(this.validatePartialForUpdate(update)) } - override async updateOutputTagMap( + override async updateOutputTagMap ( outputId: number, tagId: number, update: Partial, @@ -639,14 +633,14 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .update(this.validatePartialForUpdate(update, undefined, ['isDeleted'])) } - override async updateOutputTag(id: number, update: Partial, trx?: TrxToken): Promise { + override async updateOutputTag (id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('output_tags') .where({ outputTagId: id }) .update(this.validatePartialForUpdate(update, undefined, ['isDeleted'])) } - override async updateProvenTxReq( + override async updateProvenTxReq ( id: number | number[], update: Partial, trx?: TrxToken @@ -667,21 +661,21 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return r } - override async updateProvenTx(id: number, update: Partial, trx?: TrxToken): Promise { + override async updateProvenTx (id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('proven_txs') .where({ provenTxId: id }) .update(this.validatePartialForUpdate(update)) } - override async updateSyncState(id: number, update: Partial, trx?: TrxToken): Promise { + override async updateSyncState (id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('sync_states') .where({ syncStateId: id }) .update(this.validatePartialForUpdate(update, ['when'], ['init'])) } - override async updateTransaction( + override async updateTransaction ( id: number | number[], update: Partial, trx?: TrxToken @@ -702,7 +696,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return r } - override async updateTxLabelMap( + override async updateTxLabelMap ( transactionId: number, txLabelId: number, update: Partial, @@ -714,19 +708,19 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .update(this.validatePartialForUpdate(update, undefined, ['isDeleted'])) } - override async updateTxLabel(id: number, update: Partial, trx?: TrxToken): Promise { + override async updateTxLabel (id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('tx_labels') .where({ txLabelId: id }) .update(this.validatePartialForUpdate(update, undefined, ['isDeleted'])) } - override async updateUser(id: number, update: Partial, trx?: TrxToken): Promise { + override async updateUser (id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('users').where({ userId: id }).update(this.validatePartialForUpdate(update)) } - override async updateMonitorEvent(id: number, update: Partial, trx?: TrxToken): Promise { + override async updateMonitorEvent (id: number, update: Partial, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) return await this.toDb(trx)('monitor_events') .where({ id }) @@ -790,18 +784,18 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return q } - findCertificateFieldsQuery(args: FindCertificateFieldsArgs): Knex.QueryBuilder { + findCertificateFieldsQuery (args: FindCertificateFieldsArgs): Knex.QueryBuilder { return this.setupQuery('certificate_fields', args) } - findCertificatesQuery(args: FindCertificatesArgs): Knex.QueryBuilder { + findCertificatesQuery (args: FindCertificatesArgs): Knex.QueryBuilder { const q = this.setupQuery('certificates', args) - if (args.certifiers != null && args.certifiers.length > 0) void q.whereIn('certifier', args.certifiers) - if (args.types != null && args.types.length > 0) void q.whereIn('type', args.types) + if ((args.certifiers != null) && args.certifiers.length > 0) void q.whereIn('certifier', args.certifiers) + if ((args.types != null) && args.types.length > 0) void q.whereIn('type', args.types) return q } - findCommissionsQuery(args: FindCommissionsArgs): Knex.QueryBuilder { + findCommissionsQuery (args: FindCommissionsArgs): Knex.QueryBuilder { if (args.partial.lockingScript != null) { throw new WERR_INVALID_PARAMETER( 'partial.lockingScript', @@ -811,11 +805,11 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.setupQuery('commissions', args) } - findOutputBasketsQuery(args: FindOutputBasketsArgs): Knex.QueryBuilder { + findOutputBasketsQuery (args: FindOutputBasketsArgs): Knex.QueryBuilder { return this.setupQuery('output_baskets', args) } - findOutputsQuery(args: FindOutputsArgs, count?: boolean): Knex.QueryBuilder { + findOutputsQuery (args: FindOutputsArgs, count?: boolean): Knex.QueryBuilder { if (args.partial.lockingScript != null) { throw new WERR_INVALID_PARAMETER( 'args.partial.lockingScript', @@ -823,32 +817,30 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide ) } const q = this.setupQuery('outputs', args) - if (args.txStatus != null && args.txStatus.length > 0) { + if ((args.txStatus != null) && args.txStatus.length > 0) { void q.whereRaw( `(select status from transactions where transactions.transactionId = outputs.transactionId) in (${args.txStatus.map(s => "'" + s + "'").join(',')})` ) } - if (args.noScript === true && count !== true) { + if ((args.noScript === true) && count !== true) { const columns = outputColumnsWithoutLockingScript.map(c => `outputs.${c}`) void q.select(columns) } return q } - findOutputTagMapsQuery(args: FindOutputTagMapsArgs): Knex.QueryBuilder { + findOutputTagMapsQuery (args: FindOutputTagMapsArgs): Knex.QueryBuilder { const q = this.setupQuery('output_tags_map', args) - if (args.tagIds != null && args.tagIds.length > 0) void q.whereIn('outputTagId', args.tagIds) + if ((args.tagIds != null) && args.tagIds.length > 0) void q.whereIn('outputTagId', args.tagIds) return q } - findOutputTagsQuery(args: FindOutputTagsArgs): Knex.QueryBuilder { + findOutputTagsQuery (args: FindOutputTagsArgs): Knex.QueryBuilder { return this.setupQuery('output_tags', args) } - findProvenTxReqsQuery(args: FindProvenTxReqsArgs): Knex.QueryBuilder { - if (args.partial.rawTx != null) { - throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. ProvenTxReqs may not be found by rawTx value.') - } + findProvenTxReqsQuery (args: FindProvenTxReqsArgs): Knex.QueryBuilder { + if (args.partial.rawTx != null) { throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. ProvenTxReqs may not be found by rawTx value.') } if (args.partial.inputBEEF != null) { throw new WERR_INVALID_PARAMETER( 'args.partial.inputBEEF', @@ -856,7 +848,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide ) } const q = this.setupQuery('proven_tx_reqs', args) - if (args.status != null && args.status.length > 0) void q.whereIn('status', args.status) + if ((args.status != null) && args.status.length > 0) void q.whereIn('status', args.status) if (args.txids != null) { const txids = args.txids.filter(txid => txid !== undefined) if (txids.length > 0) void q.whereIn('txid', txids) @@ -864,10 +856,8 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return q } - findProvenTxsQuery(args: FindProvenTxsArgs): Knex.QueryBuilder { - if (args.partial.rawTx != null) { - throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. ProvenTxs may not be found by rawTx value.') - } + findProvenTxsQuery (args: FindProvenTxsArgs): Knex.QueryBuilder { + if (args.partial.rawTx != null) { throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. ProvenTxs may not be found by rawTx value.') } if (args.partial.merklePath != null) { throw new WERR_INVALID_PARAMETER( 'args.partial.merklePath', @@ -877,7 +867,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.setupQuery('proven_txs', args) } - findStaleMerkleRootsQuery(args: FindStaleMerkleRootsArgs): Knex.QueryBuilder { + findStaleMerkleRootsQuery (args: FindStaleMerkleRootsArgs): Knex.QueryBuilder { const q = this.toDb(args.trx)('proven_txs') void q.where('height', '=', args.height) void q.where('merkleRoot', '!=', args.merkleRoot) @@ -886,14 +876,12 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return q } - findSyncStatesQuery(args: FindSyncStatesArgs): Knex.QueryBuilder { + findSyncStatesQuery (args: FindSyncStatesArgs): Knex.QueryBuilder { return this.setupQuery('sync_states', args) } - findTransactionsQuery(args: FindTransactionsArgs, count?: boolean): Knex.QueryBuilder { - if (args.partial.rawTx != null) { - throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. Transactions may not be found by rawTx value.') - } + findTransactionsQuery (args: FindTransactionsArgs, count?: boolean): Knex.QueryBuilder { + if (args.partial.rawTx != null) { throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. Transactions may not be found by rawTx value.') } if (args.partial.inputBEEF != null) { throw new WERR_INVALID_PARAMETER( 'args.partial.inputBEEF', @@ -901,69 +889,57 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide ) } const q = this.setupQuery('transactions', args) - if (args.status != null && args.status.length > 0) void q.whereIn('status', args.status) + if ((args.status != null) && args.status.length > 0) void q.whereIn('status', args.status) if (args.from != null) void q.where('created_at', '>=', this.validateDateForWhere(args.from)) if (args.to != null) void q.where('created_at', '<', this.validateDateForWhere(args.to)) - if (args.noRawTx === true && count !== true) { + if ((args.noRawTx === true) && count !== true) { const columns = transactionColumnsWithoutRawTx.map(c => `transactions.${c}`) void q.select(columns) } return q } - findTxLabelMapsQuery(args: FindTxLabelMapsArgs): Knex.QueryBuilder { + findTxLabelMapsQuery (args: FindTxLabelMapsArgs): Knex.QueryBuilder { const q = this.setupQuery('tx_labels_map', args) - if (args.labelIds != null && args.labelIds.length > 0) void q.whereIn('txLabelId', args.labelIds) + if ((args.labelIds != null) && args.labelIds.length > 0) void q.whereIn('txLabelId', args.labelIds) return q } - findTxLabelsQuery(args: FindTxLabelsArgs): Knex.QueryBuilder { + findTxLabelsQuery (args: FindTxLabelsArgs): Knex.QueryBuilder { return this.setupQuery('tx_labels', args) } - findUsersQuery(args: FindUsersArgs): Knex.QueryBuilder { + findUsersQuery (args: FindUsersArgs): Knex.QueryBuilder { return this.setupQuery('users', args) } - findMonitorEventsQuery(args: FindMonitorEventsArgs): Knex.QueryBuilder { + findMonitorEventsQuery (args: FindMonitorEventsArgs): Knex.QueryBuilder { return this.setupQuery('monitor_events', args) } - override async findCertificatesAuth(auth: AuthId, args: FindCertificatesArgs): Promise { - if ( - auth.userId == null || - (args.partial.userId != null && args.partial.userId !== 0 && args.partial.userId !== auth.userId) - ) - throw new WERR_UNAUTHORIZED() + override async findCertificatesAuth (auth: AuthId, args: FindCertificatesArgs): Promise { + if (auth.userId == null || (args.partial.userId != null && args.partial.userId !== 0 && args.partial.userId !== auth.userId)) throw new WERR_UNAUTHORIZED() args.partial.userId = auth.userId return await this.findCertificates(args) } - override async findOutputBasketsAuth(auth: AuthId, args: FindOutputBasketsArgs): Promise { - if ( - auth.userId == null || - (args.partial.userId != null && args.partial.userId !== 0 && args.partial.userId !== auth.userId) - ) - throw new WERR_UNAUTHORIZED() + override async findOutputBasketsAuth (auth: AuthId, args: FindOutputBasketsArgs): Promise { + if (auth.userId == null || (args.partial.userId != null && args.partial.userId !== 0 && args.partial.userId !== auth.userId)) throw new WERR_UNAUTHORIZED() args.partial.userId = auth.userId return await this.findOutputBaskets(args) } - override async findOutputsAuth(auth: AuthId, args: FindOutputsArgs): Promise { - if ( - auth.userId == null || - (args.partial.userId != null && args.partial.userId !== 0 && args.partial.userId !== auth.userId) - ) - throw new WERR_UNAUTHORIZED() + override async findOutputsAuth (auth: AuthId, args: FindOutputsArgs): Promise { + if (auth.userId == null || (args.partial.userId != null && args.partial.userId !== 0 && args.partial.userId !== auth.userId)) throw new WERR_UNAUTHORIZED() args.partial.userId = auth.userId return await this.findOutputs(args) } - override async findCertificateFields(args: FindCertificateFieldsArgs): Promise { + override async findCertificateFields (args: FindCertificateFieldsArgs): Promise { return this.validateEntities(await this.findCertificateFieldsQuery(args)) } - override async findCertificates(args: FindCertificatesArgs): Promise { + override async findCertificates (args: FindCertificatesArgs): Promise { const q = this.findCertificatesQuery(args) let r: TableCertificateX[] = await q r = this.validateEntities(r, undefined, ['isDeleted']) @@ -980,19 +956,19 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return r } - override async findCommissions(args: FindCommissionsArgs): Promise { + override async findCommissions (args: FindCommissionsArgs): Promise { const q = this.findCommissionsQuery(args) const r = await q return this.validateEntities(r, undefined, ['isRedeemed']) } - override async findOutputBaskets(args: FindOutputBasketsArgs): Promise { + override async findOutputBaskets (args: FindOutputBasketsArgs): Promise { const q = this.findOutputBasketsQuery(args) const r = await q return this.validateEntities(r, undefined, ['isDeleted']) } - override async findOutputs(args: FindOutputsArgs): Promise { + override async findOutputs (args: FindOutputsArgs): Promise { const q = this.findOutputsQuery(args) const r = await q if (args.noScript !== true) { @@ -1003,43 +979,43 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntities(r, undefined, ['spendable', 'change']) } - override async findOutputTagMaps(args: FindOutputTagMapsArgs): Promise { + override async findOutputTagMaps (args: FindOutputTagMapsArgs): Promise { const q = this.findOutputTagMapsQuery(args) const r = await q return this.validateEntities(r, undefined, ['isDeleted']) } - override async findOutputTags(args: FindOutputTagsArgs): Promise { + override async findOutputTags (args: FindOutputTagsArgs): Promise { const q = this.findOutputTagsQuery(args) const r = await q return this.validateEntities(r, undefined, ['isDeleted']) } - override async findProvenTxReqs(args: FindProvenTxReqsArgs): Promise { + override async findProvenTxReqs (args: FindProvenTxReqsArgs): Promise { const q = this.findProvenTxReqsQuery(args) const r = await q return this.validateEntities(r, undefined, ['notified', 'wasBroadcast']) } - override async findProvenTxs(args: FindProvenTxsArgs): Promise { + override async findProvenTxs (args: FindProvenTxsArgs): Promise { const q = this.findProvenTxsQuery(args) const r = await q return this.validateEntities(r) } - override async findStaleMerkleRoots(args: FindStaleMerkleRootsArgs): Promise { + override async findStaleMerkleRoots (args: FindStaleMerkleRootsArgs): Promise { const q = this.findStaleMerkleRootsQuery(args) const r = await q return r.map((row: { merkleRoot: string }) => row.merkleRoot) } - override async findSyncStates(args: FindSyncStatesArgs): Promise { + override async findSyncStates (args: FindSyncStatesArgs): Promise { const q = this.findSyncStatesQuery(args) const r = await q return this.validateEntities(r, ['when'], ['init']) } - override async findTransactions(args: FindTransactionsArgs): Promise { + override async findTransactions (args: FindTransactionsArgs): Promise { const q = this.findTransactionsQuery(args) const r = await q if (args.noRawTx !== true) { @@ -1050,25 +1026,25 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntities(r, undefined, ['isOutgoing']) } - override async findTxLabelMaps(args: FindTxLabelMapsArgs): Promise { + override async findTxLabelMaps (args: FindTxLabelMapsArgs): Promise { const q = this.findTxLabelMapsQuery(args) const r = await q return this.validateEntities(r, undefined, ['isDeleted']) } - override async findTxLabels(args: FindTxLabelsArgs): Promise { + override async findTxLabels (args: FindTxLabelsArgs): Promise { const q = this.findTxLabelsQuery(args) const r = await q return this.validateEntities(r, undefined, ['isDeleted']) } - override async findUsers(args: FindUsersArgs): Promise { + override async findUsers (args: FindUsersArgs): Promise { const q = this.findUsersQuery(args) const r = await q return this.validateEntities(r) } - override async recentlyActiveUsers(limit = 50, trx?: TrxToken): Promise { + override async recentlyActiveUsers (limit = 50, trx?: TrxToken): Promise { await this.verifyReadyForDatabaseAccess(trx) const latestOutputs = this.toDb(trx)('outputs as o') .select('o.userId') @@ -1085,7 +1061,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntities(rows as TableUser[]) } - override async findMonitorEvents(args: FindMonitorEventsArgs): Promise { + override async findMonitorEvents (args: FindMonitorEventsArgs): Promise { const q = this.findMonitorEventsQuery(args) const r = await q return this.validateEntities(r, ['when']) @@ -1097,67 +1073,67 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return r[0]['count(*)'] } - override async countCertificateFields(args: FindCertificateFieldsArgs): Promise { + override async countCertificateFields (args: FindCertificateFieldsArgs): Promise { return await this.getCount(this.findCertificateFieldsQuery(args)) } - override async countCertificates(args: FindCertificatesArgs): Promise { + override async countCertificates (args: FindCertificatesArgs): Promise { return await this.getCount(this.findCertificatesQuery(args)) } - override async countCommissions(args: FindCommissionsArgs): Promise { + override async countCommissions (args: FindCommissionsArgs): Promise { return await this.getCount(this.findCommissionsQuery(args)) } - override async countOutputBaskets(args: FindOutputBasketsArgs): Promise { + override async countOutputBaskets (args: FindOutputBasketsArgs): Promise { return await this.getCount(this.findOutputBasketsQuery(args)) } - override async countOutputs(args: FindOutputsArgs): Promise { + override async countOutputs (args: FindOutputsArgs): Promise { return await this.getCount(this.findOutputsQuery(args, true)) } - override async countOutputTagMaps(args: FindOutputTagMapsArgs): Promise { + override async countOutputTagMaps (args: FindOutputTagMapsArgs): Promise { return await this.getCount(this.findOutputTagMapsQuery(args)) } - override async countOutputTags(args: FindOutputTagsArgs): Promise { + override async countOutputTags (args: FindOutputTagsArgs): Promise { return await this.getCount(this.findOutputTagsQuery(args)) } - override async countProvenTxReqs(args: FindProvenTxReqsArgs): Promise { + override async countProvenTxReqs (args: FindProvenTxReqsArgs): Promise { return await this.getCount(this.findProvenTxReqsQuery(args)) } - override async countProvenTxs(args: FindProvenTxsArgs): Promise { + override async countProvenTxs (args: FindProvenTxsArgs): Promise { return await this.getCount(this.findProvenTxsQuery(args)) } - override async countSyncStates(args: FindSyncStatesArgs): Promise { + override async countSyncStates (args: FindSyncStatesArgs): Promise { return await this.getCount(this.findSyncStatesQuery(args)) } - override async countTransactions(args: FindTransactionsArgs): Promise { + override async countTransactions (args: FindTransactionsArgs): Promise { return await this.getCount(this.findTransactionsQuery(args, true)) } - override async countTxLabelMaps(args: FindTxLabelMapsArgs): Promise { + override async countTxLabelMaps (args: FindTxLabelMapsArgs): Promise { return await this.getCount(this.findTxLabelMapsQuery(args)) } - override async countTxLabels(args: FindTxLabelsArgs): Promise { + override async countTxLabels (args: FindTxLabelsArgs): Promise { return await this.getCount(this.findTxLabelsQuery(args)) } - override async countUsers(args: FindUsersArgs): Promise { + override async countUsers (args: FindUsersArgs): Promise { return await this.getCount(this.findUsersQuery(args)) } - override async countMonitorEvents(args: FindMonitorEventsArgs): Promise { + override async countMonitorEvents (args: FindMonitorEventsArgs): Promise { return await this.getCount(this.findMonitorEventsQuery(args)) } - override async destroy(): Promise { + override async destroy (): Promise { this.knex.off('query', this.onQuery) this.knex.off('query-response', this.onQueryResponse) this.knex.off('query-error', this.onQueryError) @@ -1166,7 +1142,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide await this.knex?.destroy() } - override async migrate(storageName: string, storageIdentityKey: string): Promise { + override async migrate (storageName: string, storageIdentityKey: string): Promise { // Check if this is a SQLite database by looking at the Knex client config const clientName = (this.knex.client as { config?: { client?: string } }).config?.client ?? '' const isSQLite = clientName.includes('sqlite') @@ -1194,7 +1170,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return version } - override async dropAllData(): Promise { + override async dropAllData (): Promise { // Only using migrations to migrate down, don't need valid properties for settings table. const migrationSource = new KnexMigrations('test', '', '', 1024) @@ -1253,14 +1229,14 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide * or a Knex.Transaction as appropriate. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - toDb(trx?: TrxToken): Knex | Knex.Transaction { + toDb (trx?: TrxToken): Knex | Knex.Transaction { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = trx == null ? this.knex : (trx as Knex.Transaction) + const db = (trx == null) ? this.knex : trx as Knex.Transaction this.whenLastAccess = new Date() return db } - async validateRawTransaction(t: TableTransaction, trx?: TrxToken): Promise { + async validateRawTransaction (t: TableTransaction, trx?: TrxToken): Promise { // if there is no txid or there is a rawTransaction return what we have. if (t.rawTx != null || t.txid == null || t.txid === '') return @@ -1281,7 +1257,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide * * @param trx */ - async verifyReadyForDatabaseAccess(trx?: TrxToken): Promise { + async verifyReadyForDatabaseAccess (trx?: TrxToken): Promise { this._settings ??= await this.readSettings() // Always run the PRAGMA for SQLite to ensure foreign key constraints are enabled. @@ -1299,7 +1275,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide /** Convert every byte-array value to a Buffer and every undefined to null on an arbitrary object. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - private serialiseForKnex(v: any): void { + private serialiseForKnex (v: any): void { for (const key of Object.keys(v)) { const val = v[key] if (Array.isArray(val) && (val.length === 0 || typeof val[0] === 'number')) { @@ -1314,7 +1290,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide /** Apply optional date-field coercion list in-place. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - private coerceDateFields(v: any, dateFields?: string[]): void { + private coerceDateFields (v: any, dateFields?: string[]): void { if (dateFields == null) return for (const df of dateFields) { if (v[df] != null) v[df] = this.validateOptionalEntityDate(v[df]) @@ -1323,7 +1299,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide /** Apply optional boolean-field coercion list in-place. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - private coerceBooleanFields(v: any, booleanFields?: string[]): void { + private coerceBooleanFields (v: any, booleanFields?: string[]): void { if (booleanFields == null) return for (const df of booleanFields) { if (v[df] !== undefined) v[df] = (v[df] as unknown) != null && (v[df] as unknown) !== false ? 1 : 0 @@ -1362,7 +1338,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide trx?: TrxToken, dateFields?: string[], booleanFields?: string[] - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { await this.verifyReadyForDatabaseAccess(trx) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1378,7 +1354,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return v } - override async getLabelsForTransactionId(transactionId?: number, trx?: TrxToken): Promise { + override async getLabelsForTransactionId (transactionId?: number, trx?: TrxToken): Promise { if (transactionId === undefined) return [] const labels = await this.toDb(trx)('tx_labels') .join('tx_labels_map', 'tx_labels_map.txLabelId', 'tx_labels.txLabelId') @@ -1388,7 +1364,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntities(labels, undefined, ['isDeleted']) } - override async getTagsForOutputId(outputId: number, trx?: TrxToken): Promise { + override async getTagsForOutputId (outputId: number, trx?: TrxToken): Promise { const tags = await this.toDb(trx)('output_tags') .join('output_tags_map', 'output_tags_map.outputTagId', 'output_tags.outputTagId') .where('output_tags_map.outputId', outputId) @@ -1397,11 +1373,11 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return this.validateEntities(tags, undefined, ['isDeleted']) } - override async purgeData(params: PurgeParams, trx?: TrxToken): Promise { + override async purgeData (params: PurgeParams, trx?: TrxToken): Promise { return await purgeData(this, params, trx) } - override async reviewStatus(args: { agedLimit: Date; trx?: TrxToken }): Promise<{ log: string }> { + override async reviewStatus (args: { agedLimit: Date, trx?: TrxToken }): Promise<{ log: string }> { return await reviewStatus(this, args) } @@ -1413,7 +1389,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide * - unproven * - sending (if excludeSending is false) */ - async countChangeInputs(userId: number, basketId: number, excludeSending: boolean): Promise { + async countChangeInputs (userId: number, basketId: number, excludeSending: boolean): Promise { const status: TransactionStatus[] = ['completed', 'unproven'] if (!excludeSending) status.push('sending') const q = this.knex('outputs as o') @@ -1437,7 +1413,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return count } - override async findOutputsByIds(outputIds: number[], trx?: TrxToken): Promise> { + override async findOutputsByIds (outputIds: number[], trx?: TrxToken): Promise> { const byId: Record = {} if (outputIds.length < 1) return byId const rows = await this.toDb(trx)('outputs').whereIn('outputId', outputIds).select('*') @@ -1451,9 +1427,9 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return byId } - override async findOutputsByOutpoints( + override async findOutputsByOutpoints ( userId: number, - outpoints: Array<{ txid: string; vout: number }>, + outpoints: Array<{ txid: string, vout: number }>, trx?: TrxToken ): Promise> { const byOutpoint: Record = {} @@ -1476,9 +1452,9 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return byOutpoint } - override async findOutputsByOutpointsForUpdate( + override async findOutputsByOutpointsForUpdate ( userId: number, - outpoints: Array<{ txid: string; vout: number }>, + outpoints: Array<{ txid: string, vout: number }>, trx: TrxToken ): Promise> { const byOutpoint: Record = {} @@ -1498,7 +1474,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return byOutpoint } - override async findOrInsertOutputBasketsBulk( + override async findOrInsertOutputBasketsBulk ( userId: number, names: string[], trx?: TrxToken @@ -1520,7 +1496,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return byName } - override async findOrInsertOutputTagsBulk( + override async findOrInsertOutputTagsBulk ( userId: number, tags: string[], trx?: TrxToken @@ -1542,7 +1518,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return byTag } - override async findOrInsertTxLabelsBulk( + override async findOrInsertTxLabelsBulk ( userId: number, labels: string[], trx?: TrxToken @@ -1564,7 +1540,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return byLabel } - override async sumSpendableSatoshisInBasket( + override async sumSpendableSatoshisInBasket ( userId: number, basketId: number, excludeSending: boolean, @@ -1579,7 +1555,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .whereIn('t.status', status) .sum({ totalSatoshis: 'o.satoshis' }) .first() - const total = row != null ? (row as Record).totalSatoshis : undefined + const total = (row != null) ? (row as Record).totalSatoshis : undefined return Number(total ?? 0) } @@ -1588,7 +1564,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide * * Transactionally allocate the output such that */ - async allocateChangeInput( + async allocateChangeInput ( userId: number, basketId: number, targetSatoshis: number, @@ -1618,7 +1594,9 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide .whereNot('o.derivationSuffix', '') .whereNull('o.spentBy') .whereNotExists(function () { - void this.select(1).from('action_batch_outputs as abo').whereRaw('abo.outputId = o.outputId') + void this.select(1) + .from('action_batch_outputs as abo') + .whereRaw('abo.outputId = o.outputId') }) .whereIn('t.status', status) .select('o.*') @@ -1696,9 +1674,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide if (booleanFields != null) { for (const df of booleanFields) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((entity as any)[df] !== undefined) - (entity as any)[df] = - (entity as any)[df] !== 0 && (entity as any)[df] != null && (entity as any)[df] !== false + if ((entity as any)[df] !== undefined) (entity as any)[df] = (entity as any)[df] !== 0 && (entity as any)[df] != null && (entity as any)[df] !== false } } this.deserialiseFromKnex(entity) @@ -1717,7 +1693,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide return entities } - async adminStats(adminIdentityKey: string): Promise { + async adminStats (adminIdentityKey: string): Promise { if (this.dbtype !== 'MySQL') throw new WERR_NOT_IMPLEMENTED('adminStats, only MySQL is supported') const monitorEvent = verifyOneOrNone( @@ -1727,8 +1703,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide paged: { limit: 1 } }) ) - const monitorStats: ServicesCallHistory | undefined = - monitorEvent != null ? JSON.parse(monitorEvent.details as string) : undefined + const monitorStats: ServicesCallHistory | undefined = (monitorEvent != null) ? JSON.parse(monitorEvent.details as string) : undefined const servicesStats = this.getServices().getServicesCallHistory(true) const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() diff --git a/packages/wallet/wallet-toolbox/src/storage/StorageReader.ts b/packages/wallet/wallet-toolbox/src/storage/StorageReader.ts index 70f074bb2..6e5553342 100644 --- a/packages/wallet/wallet-toolbox/src/storage/StorageReader.ts +++ b/packages/wallet/wallet-toolbox/src/storage/StorageReader.ts @@ -42,75 +42,74 @@ export abstract class StorageReader implements sdk.WalletStorageSyncReader { readonly telemetry: Telemetry _settings?: TableSettings whenLastAccess?: Date - get dbtype(): DBType | undefined { + get dbtype (): DBType | undefined { return this._settings?.dbtype } - constructor(options: StorageReaderOptions) { + constructor (options: StorageReaderOptions) { this.chain = options.chain this.telemetry = new Telemetry(options.telemetry) } - isAvailable(): boolean { + isAvailable (): boolean { return this._settings != null } - async makeAvailable(): Promise { + async makeAvailable (): Promise { if (this._settings != null) return this._settings this._settings = await this.readSettings() return this._settings } - getSettings(): TableSettings { - if (this._settings == null) - throw new sdk.WERR_INVALID_OPERATION('must call "makeAvailable" before accessing "settings"') + getSettings (): TableSettings { + if (this._settings == null) throw new sdk.WERR_INVALID_OPERATION('must call "makeAvailable" before accessing "settings"') return this._settings } - isStorageProvider(): boolean { + isStorageProvider (): boolean { return false } - abstract destroy(): Promise + abstract destroy (): Promise abstract transaction(scope: (trx: sdk.TrxToken) => Promise, trx?: sdk.TrxToken): Promise - abstract readSettings(trx?: sdk.TrxToken): Promise - - abstract findCertificateFields(args: sdk.FindCertificateFieldsArgs): Promise - abstract findCertificates(args: sdk.FindCertificatesArgs): Promise - abstract findCommissions(args: sdk.FindCommissionsArgs): Promise - abstract findMonitorEvents(args: sdk.FindMonitorEventsArgs): Promise - abstract findOutputBaskets(args: sdk.FindOutputBasketsArgs): Promise - abstract findOutputs(args: sdk.FindOutputsArgs): Promise - abstract findOutputTags(args: sdk.FindOutputTagsArgs): Promise - abstract findSyncStates(args: sdk.FindSyncStatesArgs): Promise - abstract findTransactions(args: sdk.FindTransactionsArgs): Promise - abstract findTxLabels(args: sdk.FindTxLabelsArgs): Promise - abstract findUsers(args: sdk.FindUsersArgs): Promise - - abstract countCertificateFields(args: sdk.FindCertificateFieldsArgs): Promise - abstract countCertificates(args: sdk.FindCertificatesArgs): Promise - abstract countCommissions(args: sdk.FindCommissionsArgs): Promise - abstract countMonitorEvents(args: sdk.FindMonitorEventsArgs): Promise - abstract countOutputBaskets(args: sdk.FindOutputBasketsArgs): Promise - abstract countOutputs(args: sdk.FindOutputsArgs): Promise - abstract countOutputTags(args: sdk.FindOutputTagsArgs): Promise - abstract countSyncStates(args: sdk.FindSyncStatesArgs): Promise - abstract countTransactions(args: sdk.FindTransactionsArgs): Promise - abstract countTxLabels(args: sdk.FindTxLabelsArgs): Promise - abstract countUsers(args: sdk.FindUsersArgs): Promise - - abstract getProvenTxsForUser(args: sdk.FindForUserSincePagedArgs): Promise - abstract getProvenTxReqsForUser(args: sdk.FindForUserSincePagedArgs): Promise - abstract getTxLabelMapsForUser(args: sdk.FindForUserSincePagedArgs): Promise - abstract getOutputTagMapsForUser(args: sdk.FindForUserSincePagedArgs): Promise - - async findUserByIdentityKey(key: string): Promise { + abstract readSettings (trx?: sdk.TrxToken): Promise + + abstract findCertificateFields (args: sdk.FindCertificateFieldsArgs): Promise + abstract findCertificates (args: sdk.FindCertificatesArgs): Promise + abstract findCommissions (args: sdk.FindCommissionsArgs): Promise + abstract findMonitorEvents (args: sdk.FindMonitorEventsArgs): Promise + abstract findOutputBaskets (args: sdk.FindOutputBasketsArgs): Promise + abstract findOutputs (args: sdk.FindOutputsArgs): Promise + abstract findOutputTags (args: sdk.FindOutputTagsArgs): Promise + abstract findSyncStates (args: sdk.FindSyncStatesArgs): Promise + abstract findTransactions (args: sdk.FindTransactionsArgs): Promise + abstract findTxLabels (args: sdk.FindTxLabelsArgs): Promise + abstract findUsers (args: sdk.FindUsersArgs): Promise + + abstract countCertificateFields (args: sdk.FindCertificateFieldsArgs): Promise + abstract countCertificates (args: sdk.FindCertificatesArgs): Promise + abstract countCommissions (args: sdk.FindCommissionsArgs): Promise + abstract countMonitorEvents (args: sdk.FindMonitorEventsArgs): Promise + abstract countOutputBaskets (args: sdk.FindOutputBasketsArgs): Promise + abstract countOutputs (args: sdk.FindOutputsArgs): Promise + abstract countOutputTags (args: sdk.FindOutputTagsArgs): Promise + abstract countSyncStates (args: sdk.FindSyncStatesArgs): Promise + abstract countTransactions (args: sdk.FindTransactionsArgs): Promise + abstract countTxLabels (args: sdk.FindTxLabelsArgs): Promise + abstract countUsers (args: sdk.FindUsersArgs): Promise + + abstract getProvenTxsForUser (args: sdk.FindForUserSincePagedArgs): Promise + abstract getProvenTxReqsForUser (args: sdk.FindForUserSincePagedArgs): Promise + abstract getTxLabelMapsForUser (args: sdk.FindForUserSincePagedArgs): Promise + abstract getOutputTagMapsForUser (args: sdk.FindForUserSincePagedArgs): Promise + + async findUserByIdentityKey (key: string): Promise { return verifyOneOrNone(await this.findUsers({ partial: { identityKey: key } })) } - async getSyncChunk(args: sdk.RequestSyncChunkArgs): Promise { + async getSyncChunk (args: sdk.RequestSyncChunkArgs): Promise { return await getSyncChunk(this, args) } @@ -119,7 +118,7 @@ export abstract class StorageReader implements sdk.WalletStorageSyncReader { * @param date * @returns */ - validateEntityDate(date: DateInput): StorageDate { + validateEntityDate (date: DateInput): StorageDate { if (this.dbtype == null) throw new sdk.WERR_INTERNAL('must call verifyReadyForDatabaseAccess first') let r: StorageDate = this.validateDate(date) switch (this.dbtype) { @@ -141,10 +140,13 @@ export abstract class StorageReader implements sdk.WalletStorageSyncReader { * @param useNowAsDefault if true and date is null or undefiend, set to current time. * @returns */ - validateOptionalEntityDate(date: OptionalDateInput, useNowAsDefault?: boolean): StorageDate | undefined { + validateOptionalEntityDate ( + date: OptionalDateInput, + useNowAsDefault?: boolean + ): StorageDate | undefined { if (this.dbtype == null) throw new sdk.WERR_INTERNAL('must call verifyReadyForDatabaseAccess first') let r: StorageDate | undefined = this.validateOptionalDate(date) - if (r == null && useNowAsDefault === true) r = new Date() + if ((r == null) && (useNowAsDefault === true)) r = new Date() switch (this.dbtype) { case 'IndexedDB': case 'MySQL': @@ -158,19 +160,19 @@ export abstract class StorageReader implements sdk.WalletStorageSyncReader { return r } - validateDate(date: DateInput): Date { + validateDate (date: DateInput): Date { let r: Date if (date instanceof Date) r = date else r = new Date(date) return r } - validateOptionalDate(date: OptionalDateInput): Date | undefined { + validateOptionalDate (date: OptionalDateInput): Date | undefined { if (date === null || date === undefined) return undefined return this.validateDate(date) } - validateDateForWhere(date: DateInput): DateInput { + validateDateForWhere (date: DateInput): DateInput { if (this.dbtype == null) throw new sdk.WERR_INTERNAL('must call verifyReadyForDatabaseAccess first') if (typeof date === 'number') date = validateSecondsSinceEpoch(date) const vdate = verifyTruthy(this.validateDate(date)) diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnex.telemetry.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnex.telemetry.test.ts index 31830fd5c..ba3a6531d 100644 --- a/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnex.telemetry.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnex.telemetry.test.ts @@ -66,4 +66,57 @@ describe('StorageKnex telemetry', () => { expect(events.at(-1)?.spanStatus).toBe('cancelled') expect(knex.destroy).toHaveBeenCalled() }) + + it.each([ + ['mysql2', 'mysql'], + ['pg', 'postgresql'], + ['custom-adapter', 'unknown'] + ])('normalizes the %s client without depending on query payloads', async (client, system) => { + const events: TelemetryEvent[] = [] + const knex = new EventEmitter() as any + knex.client = { config: { client } } + knex.destroy = jest.fn(async () => undefined) + const storage = new StorageKnex({ + ...StorageKnex.defaultOptions(), + chain: 'test', + knex, + telemetry: { + sink: { capture: event => events.push(event) } + } + }) + + knex.emit('query', { method: 'select' }) + knex.emit('query-response', undefined, { __knexQueryUid: 'not-started' }) + knex.emit('query', { __knexQueryUid: 'query', method: undefined }) + knex.emit('query-response', undefined, { __knexQueryUid: 'query' }) + knex.emit('query-error', new Error('late error'), { __knexQueryUid: 'query' }) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + spanStatus: 'ok', + attributes: { + 'db.system': system, + 'db.operation': 'unknown' + } + }) + await storage.destroy() + }) + + it('does not install query listeners when telemetry is disabled', async () => { + const knex = new EventEmitter() as any + knex.client = { config: {} } + knex.destroy = jest.fn(async () => undefined) + const storage = new StorageKnex({ + ...StorageKnex.defaultOptions(), + chain: 'test', + knex, + telemetry: { + enabled: false, + sink: { capture: jest.fn() } + } + }) + + expect(knex.listenerCount('query')).toBe(0) + await storage.destroy() + }) }) diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts index 50ab61e8d..d7a8dbf91 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts @@ -1,15 +1,12 @@ -import { WalletInterface, WalletLoggerInterface } from '@bsv/sdk' +import { + WalletInterface, + WalletLoggerInterface +} from '@bsv/sdk' import { WalletErrorFromJson } from '../../sdk/WalletErrorFromJson' import { logWalletError } from '../../WalletLogger' import { formatTraceparent } from '../../utility/traceContext' import { StorageClientBase, type StorageClientOptions } from './StorageClientBase' -import { - BINARY_ENCODING, - BINARY_ENCODING_HEADER, - BINARY_REQUEST_ENCODING_HEADER, - parseJsonRpc, - stringifyJsonRpc -} from './BinaryJson' +import { BINARY_ENCODING, BINARY_ENCODING_HEADER, BINARY_REQUEST_ENCODING_HEADER, parseJsonRpc, stringifyJsonRpc } from './BinaryJson' /** * `StorageClient` implements the `WalletStorageProvider` interface which allows it to @@ -26,7 +23,7 @@ import { * For details of the API implemented, follow the "See also" link for the `WalletStorageProvider` interface. */ export class StorageClient extends StorageClientBase { - constructor(wallet: WalletInterface, endpointUrl: string, options: StorageClientOptions = {}) { + constructor (wallet: WalletInterface, endpointUrl: string, options: StorageClientOptions = {}) { super(wallet, endpointUrl, options) } diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts index 05d9f6720..9ad7c1c13 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts @@ -7,14 +7,18 @@ import { MakeWalletLogger, - WalletInterface, - WalletLoggerInterface, Telemetry, TelemetryConfig, - TelemetrySpan + TelemetrySpan, + WalletInterface, + WalletLoggerInterface } from '@bsv/sdk' import express, { Request, Response } from 'express' -import { AuthMiddlewareOptions, AuthRequest, createAuthMiddleware } from '@bsv/auth-express-middleware' +import { + AuthMiddlewareOptions, + AuthRequest, + createAuthMiddleware +} from '@bsv/auth-express-middleware' import { createPaymentMiddleware } from '@bsv/payment-express-middleware' import { Options as RateLimitOptions, rateLimit } from 'express-rate-limit' import { Wallet } from '../../Wallet' @@ -26,14 +30,13 @@ import { validateDate, validateEntity, validateEntities, validateSyncChunkEntiti import { WalletError } from '../../sdk/WalletError' import { logWalletError } from '../../WalletLogger' import { parseTraceparent } from '../../utility/traceContext' +import { BINARY_ENCODING, BINARY_ENCODING_HEADER, BINARY_REQUEST_ENCODING_HEADER, decodeBinaryJsonValue, stringifyJsonRpc } from './BinaryJson' import { - BINARY_ENCODING, - BINARY_ENCODING_HEADER, - BINARY_REQUEST_ENCODING_HEADER, - decodeBinaryJsonValue, - stringifyJsonRpc -} from './BinaryJson' -import { authenticatedIdentityKey, configureTrustProxy, rateLimitOptions, TrustProxySetting } from './RateLimitPolicy' + authenticatedIdentityKey, + configureTrustProxy, + rateLimitOptions, + TrustProxySetting +} from './RateLimitPolicy' import { bodyParserErrorHandler, concurrencyLimit, @@ -50,7 +53,10 @@ import { decompressActionBatchPack, supportedActionBatchPackEncodings } from '../../utility/actionBatchPack' -import { ACTION_BATCH_MAX_PACK_BYTES, ACTION_BATCH_MAX_PACK_ITEMS } from '../methods/actionBatchBlobs' +import { + ACTION_BATCH_MAX_PACK_BYTES, + ACTION_BATCH_MAX_PACK_ITEMS +} from '../methods/actionBatchBlobs' const storageRpcMethods = new Set([ 'abortAction', @@ -130,15 +136,19 @@ interface RpcDispatchResult { result?: unknown } -function requiredAuthenticatedIdentityKey(req: Request): string { +function requiredAuthenticatedIdentityKey (req: Request): string { const identityKey = (req as AuthRequest).auth?.identityKey - if (typeof identityKey !== 'string' || identityKey.trim() === '' || identityKey === 'unknown') { + if ( + typeof identityKey !== 'string' || + identityKey.trim() === '' || + identityKey === 'unknown' + ) { throw new WERR_UNAUTHORIZED('authenticated request identity is required') } return identityKey } -function firstRequestHeader(req: Request, name: string): string | undefined { +function firstRequestHeader (req: Request, name: string): string | undefined { const value = req.headers[name.toLowerCase()] return Array.isArray(value) ? value[0] : value } @@ -213,7 +223,7 @@ export class StorageServer { private readonly telemetry: Telemetry private readonly telemetryConfig?: TelemetryConfig - constructor(storage: StorageProvider, options: WalletStorageServerOptions) { + constructor (storage: StorageProvider, options: WalletStorageServerOptions) { this.storage = storage this.port = options.port this.wallet = options.wallet @@ -248,7 +258,7 @@ export class StorageServer { this.setupRoutes() } - private setupShortReqLogging(): void { + private setupShortReqLogging (): void { this.app.use((req: Request, res: Response, next: express.NextFunction) => { const contentLength = Number(req.headers['content-length'] || 0) @@ -263,9 +273,7 @@ export class StorageServer { ua: req.headers['user-agent'] || '-' } const traceContext = firstRequestHeader(req, 'X-Cloud-Trace-Context')?.split('/')[0] - if (traceContext) { - logObj['logging.googleapis.com/trace'] = `projects/computing-with-integrity/traces/${traceContext}` - } + if (traceContext) { logObj['logging.googleapis.com/trace'] = `projects/computing-with-integrity/traces/${traceContext}` } // Request bodies and BSV auth/payment headers can contain sensitive // material. Short-request logging records metadata only. @@ -276,23 +284,22 @@ export class StorageServer { }) } - private setupRoutes(): void { + private setupRoutes (): void { configureTrustProxy(this.app, this.trustProxy) this.app.disable('x-powered-by') - this.app.use( - securityHeaders({ - environmentPrefix: 'WALLET_STORAGE', - ...this.securityHeadersPolicy - }) - ) - this.app.use( - corsPolicy({ - environmentPrefix: 'WALLET_STORAGE', - allowedOrigins: this.allowedOrigins, - methods: ['GET', 'PUT', 'POST', 'OPTIONS'] - }) - ) - this.app.use(concurrencyLimit('WALLET_STORAGE', this.maxConcurrentRequests)) + this.app.use(securityHeaders({ + environmentPrefix: 'WALLET_STORAGE', + ...this.securityHeadersPolicy + })) + this.app.use(corsPolicy({ + environmentPrefix: 'WALLET_STORAGE', + allowedOrigins: this.allowedOrigins, + methods: ['GET', 'PUT', 'POST', 'OPTIONS'] + })) + this.app.use(concurrencyLimit( + 'WALLET_STORAGE', + this.maxConcurrentRequests + )) this.app.use( rateLimit( @@ -313,19 +320,15 @@ export class StorageServer { // decoded JSON value while keeping user-controlled strings inert even if a // consumer embeds a response in an HTML context. this.app.set('json escape', true) - this.app.use( - express.json({ - limit: readBodyLimitBytes('WALLET_STORAGE_JSON', 30 * 1024 * 1024) - }) - ) + this.app.use(express.json({ + limit: readBodyLimitBytes('WALLET_STORAGE_JSON', 30 * 1024 * 1024) + })) // Authentication must see the exact binary body bytes, so parse octet // streams before the auth middleware just as JSON is parsed above. - this.app.use( - express.raw({ - type: 'application/octet-stream', - limit: readBodyLimitBytes('WALLET_STORAGE_BINARY', 8 * 1024 * 1024) - }) - ) + this.app.use(express.raw({ + type: 'application/octet-stream', + limit: readBodyLimitBytes('WALLET_STORAGE_BINARY', 8 * 1024 * 1024) + })) this.app.use(bodyParserErrorHandler) this.app.get('/robots.txt', (req: Request, res: Response) => { @@ -344,15 +347,13 @@ export class StorageServer { } if (this.sessionManager != null) options.sessionManager = this.sessionManager this.app.use(createAuthMiddleware(options)) - const authenticatedRateLimit = rateLimit( - rateLimitOptions( - { windowMs: 60_000, limit: 1_000 }, - { - keyGenerator: authenticatedIdentityKey, - ...this.rateLimitOptions - } - ) - ) + const authenticatedRateLimit = rateLimit(rateLimitOptions( + { windowMs: 60_000, limit: 1_000 }, + { + keyGenerator: authenticatedIdentityKey, + ...this.rateLimitOptions + } + )) this.app.use(authenticatedRateLimit) if (this.monetize) { this.app.use( @@ -363,59 +364,74 @@ export class StorageServer { ) } - this.app.put('/action-batch/:batchId/pack', async (req: Request, res: Response) => { - try { - const auth = await this.authenticatedAuth(req, true) - const batchId = String(req.params.batchId) - // Express types request bodies as `any`, and other body parsers can - // produce strings, arrays, or objects. Keep the runtime narrowing - // inline so both the transport and static data-flow analysis can see - // that only a real byte view reaches the binary pack decoder. - const rawBody: unknown = req.body - if (typeof rawBody !== 'object' || rawBody === null || !(rawBody instanceof Uint8Array)) { - throw new TypeError('binary action batch body required') + this.app.put( + '/action-batch/:batchId/pack', + async (req: Request, res: Response) => { + try { + const auth = await this.authenticatedAuth(req, true) + const batchId = String(req.params.batchId) + // Express types request bodies as `any`, and other body parsers can + // produce strings, arrays, or objects. Keep the runtime narrowing + // inline so both the transport and static data-flow analysis can see + // that only a real byte view reaches the binary pack decoder. + const rawBody: unknown = req.body + if ( + typeof rawBody !== 'object' || + rawBody === null || + !(rawBody instanceof Uint8Array) + ) { + throw new TypeError('binary action batch body required') + } + // Re-wrap without copying. Besides normalizing Buffer subclasses, + // this makes the decoder's runtime type independent of req.body. + const body = new Uint8Array(rawBody.buffer, rawBody.byteOffset, rawBody.byteLength) + const requestedEncoding = req.header(ACTION_BATCH_PACK_ENCODING_HEADER) ?? 'identity' + const encoding = supportedActionBatchPackEncodings() + .find(candidate => candidate === requestedEncoding) + if (encoding == null) throw new TypeError(`unsupported action batch pack encoding ${requestedEncoding}`) + const frame = await decompressActionBatchPack(body, encoding, ACTION_BATCH_MAX_PACK_BYTES) + const items = decodeActionBatchPack( + frame, + ACTION_BATCH_MAX_PACK_BYTES, + ACTION_BATCH_MAX_PACK_ITEMS + ) + await this.storage.putActionBatchPack(auth, { + batchId, + items, + maxPackBytes: ACTION_BATCH_MAX_PACK_BYTES, + maxItems: ACTION_BATCH_MAX_PACK_ITEMS + }) + res.status(200).json({ uploaded: true }) + } catch (error: unknown) { + const json = WalletError.unknownToJson(error) + res.status(400).json(JSON.parse(json)) } - // Re-wrap without copying. Besides normalizing Buffer subclasses, - // this makes the decoder's runtime type independent of req.body. - const body = new Uint8Array(rawBody.buffer, rawBody.byteOffset, rawBody.byteLength) - const requestedEncoding = req.header(ACTION_BATCH_PACK_ENCODING_HEADER) ?? 'identity' - const encoding = supportedActionBatchPackEncodings().find(candidate => candidate === requestedEncoding) - if (encoding == null) throw new TypeError(`unsupported action batch pack encoding ${requestedEncoding}`) - const frame = await decompressActionBatchPack(body, encoding, ACTION_BATCH_MAX_PACK_BYTES) - const items = decodeActionBatchPack(frame, ACTION_BATCH_MAX_PACK_BYTES, ACTION_BATCH_MAX_PACK_ITEMS) - await this.storage.putActionBatchPack(auth, { - batchId, - items, - maxPackBytes: ACTION_BATCH_MAX_PACK_BYTES, - maxItems: ACTION_BATCH_MAX_PACK_ITEMS - }) - res.status(200).json({ uploaded: true }) - } catch (error: unknown) { - const json = WalletError.unknownToJson(error) - res.status(400).json(JSON.parse(json)) } - }) + ) - this.app.put('/action-batch/:batchId/blob/:digest', async (req: Request, res: Response) => { - try { - const auth = await this.authenticatedAuth(req, true) - const batchId = String(req.params.batchId) - const digest = String(req.params.digest) - const body = req.body - if (!(body instanceof Uint8Array)) throw new TypeError('binary action batch body required') - await this.storage.putActionBatchBlob(auth, { batchId, digest, bytes: body }) - res.status(200).json({ uploaded: true }) - } catch (error: unknown) { - const json = WalletError.unknownToJson(error) - res.status(400).json(JSON.parse(json)) + this.app.put( + '/action-batch/:batchId/blob/:digest', + async (req: Request, res: Response) => { + try { + const auth = await this.authenticatedAuth(req, true) + const batchId = String(req.params.batchId) + const digest = String(req.params.digest) + const body = req.body + if (!(body instanceof Uint8Array)) throw new TypeError('binary action batch body required') + await this.storage.putActionBatchBlob(auth, { batchId, digest, bytes: body }) + res.status(200).json({ uploaded: true }) + } catch (error: unknown) { + const json = WalletError.unknownToJson(error) + res.status(400).json(JSON.parse(json)) + } } - }) + ) // A single POST endpoint for JSON-RPC: this.app.post('/', this.handleRpcRequest.bind(this)) } - private async handleRpcRequest(req: Request, res: Response): Promise { + private async handleRpcRequest (req: Request, res: Response): Promise { if (!this.telemetry.enabled) return await this.handleRpcRequestCore(req, res) return await this.telemetry.withSpan( 'wallet.storage.rpc', @@ -432,7 +448,11 @@ export class StorageServer { ) } - private async handleRpcRequestCore(req: Request, res: Response, rpcSpan?: TelemetrySpan): Promise { + private async handleRpcRequestCore ( + req: Request, + res: Response, + rpcSpan?: TelemetrySpan + ): Promise { const useBinary = req.header(BINARY_ENCODING_HEADER) === BINARY_ENCODING const requestUsesBinary = req.header(BINARY_REQUEST_ENCODING_HEADER) === BINARY_ENCODING if (useBinary) res.set(BINARY_ENCODING_HEADER, BINARY_ENCODING) @@ -447,16 +467,11 @@ export class StorageServer { try { const dispatch = await this.dispatchRpcCall(method, params, req, logObj, rpcSpan) if (!dispatch.found) { - return this.sendRpc( - res, - useBinary, - { - jsonrpc: '2.0', - error: { code: -32601, message: `Method not found: ${method}` }, - id - }, - 400 - ) + return this.sendRpc(res, useBinary, { + jsonrpc: '2.0', + error: { code: -32601, message: `Method not found: ${method}` }, + id + }, 400) } return this.sendRpc(res, useBinary, { jsonrpc: '2.0', result: dispatch.result, id }) } catch (error: unknown) { @@ -464,7 +479,7 @@ export class StorageServer { } } - private traceHttpRequest(req: Request, res: Response, next: express.NextFunction): void { + private traceHttpRequest (req: Request, res: Response, next: express.NextFunction): void { const span = this.telemetry.startSpan('wallet.storage.http.request', { component: 'wallet-storage-server', kind: 'server', @@ -497,14 +512,14 @@ export class StorageServer { next() } - private sendRpc(res: Response, useBinary: boolean, payload: unknown, status: number = 200): Response { + private sendRpc (res: Response, useBinary: boolean, payload: unknown, status: number = 200): Response { res.set('X-Content-Type-Options', 'nosniff') // Normalize with the negotiated binary replacer, then let Express emit // the JSON response through its escaping-aware JSON sink. return res.status(status).json(JSON.parse(stringifyJsonRpc(payload, useBinary))) } - private sendRpcError(res: Response, useBinary: boolean, id: unknown, error: unknown): Response { + private sendRpcError (res: Response, useBinary: boolean, id: unknown, error: unknown): Response { /** * Convert errors to standard JSON object format that can be converted back * to WalletError derived objects on the client side and re-thrown. @@ -517,7 +532,12 @@ export class StorageServer { }) } - private createRpcLog(req: Request, method: string, id: unknown, params: any[]): Record | undefined { + private createRpcLog ( + req: Request, + method: string, + id: unknown, + params: any[] + ): Record | undefined { if (!this.logRpcRequests) return undefined const logObj: Record = { @@ -538,7 +558,7 @@ export class StorageServer { return logObj } - private async dispatchRpcCall( + private async dispatchRpcCall ( method: string, params: any[], req: Request, @@ -547,7 +567,9 @@ export class StorageServer { ): Promise { if (!storageRpcMethods.has(method)) return { found: false } - const storageMethod = method === 'findOutputBaskets' ? 'findOutputBasketsAuth' : method + const storageMethod = method === 'findOutputBaskets' + ? 'findOutputBasketsAuth' + : method const storageHandler = (this.storage as any)[storageMethod] if (typeof storageHandler !== 'function') return { found: false } @@ -576,7 +598,7 @@ export class StorageServer { } } - private async traceRpcStep( + private async traceRpcStep ( name: string, parent: TelemetrySpan | undefined, callback: () => Promise | T, @@ -595,7 +617,7 @@ export class StorageServer { ) } - private async authorizeRpcCall( + private async authorizeRpcCall ( method: string, params: any[], req: Request, @@ -625,14 +647,14 @@ export class StorageServer { } } - private logIgnoredDestroy(logObj?: Record): void { + private logIgnoredDestroy (logObj?: Record): void { if (logObj == null) return logObj.result = undefined logObj.comment = 'IGNORED' console.log(JSON.stringify(logObj)) } - private authorizeAdminStats(params: any[], req: Request): void { + private authorizeAdminStats (params: any[], req: Request): void { const identityKey = requiredAuthenticatedIdentityKey(req) if (params[0] !== identityKey) { throw new WERR_UNAUTHORIZED('function may only access authenticated admin user.') @@ -642,7 +664,7 @@ export class StorageServer { } } - private async authorizeStandardRpcCall(method: string, params: any[], req: Request): Promise { + private async authorizeStandardRpcCall (method: string, params: any[], req: Request): Promise { if (authIdRpcMethods.has(method)) { await this.bindAuthenticatedAuth(params, req, actionBatchRpcMethods.has(method)) return @@ -650,7 +672,7 @@ export class StorageServer { await this.validateParam0(params, req) } - private createRpcLogger(method: string, params: any[]): WalletLoggerInterface | undefined { + private createRpcLogger (method: string, params: any[]): WalletLoggerInterface | undefined { if (this.makeLogger == null || params[1] == null || typeof params[1] !== 'object') return undefined const logger = this.makeLogger(params[1].logger) @@ -663,7 +685,7 @@ export class StorageServer { return logger } - private finishRpcLogging(logger: WalletLoggerInterface | undefined, result: any): void { + private finishRpcLogging (logger: WalletLoggerInterface | undefined, result: any): void { if (logger == null) return logger.groupEnd() @@ -674,12 +696,12 @@ export class StorageServer { } } - private async authenticatedAuth(req: Request, requireActive: boolean): Promise { + private async authenticatedAuth (req: Request, requireActive: boolean): Promise { const identityKey = requiredAuthenticatedIdentityKey(req) const { user } = await this.storage.findOrInsertUser(identityKey) const isActive = user.activeStorage === this.storage.getSettings().storageIdentityKey if (requireActive && !isActive) { - throw new WERR_NOT_ACTIVE("action batch methods require the authenticated user's active storage provider") + throw new WERR_NOT_ACTIVE('action batch methods require the authenticated user\'s active storage provider') } return { identityKey, @@ -688,7 +710,7 @@ export class StorageServer { } } - private async bindAuthenticatedAuth(params: any[], req: Request, requireActive: boolean): Promise { + private async bindAuthenticatedAuth (params: any[], req: Request, requireActive: boolean): Promise { if (!Array.isArray(params)) throw new WERR_UNAUTHORIZED('authenticated RPC parameters are required') const claimed = typeof params[0] === 'object' && params[0] != null ? params[0] : {} const identityKey = requiredAuthenticatedIdentityKey(req) @@ -703,15 +725,13 @@ export class StorageServer { } } - private async validateParam0(params: any[], req: Request): Promise { + private async validateParam0 (params: any[], req: Request): Promise { if (!Array.isArray(params)) throw new WERR_UNAUTHORIZED('authenticated RPC parameters are required') if (typeof params[0] !== 'object' || !params[0]) { params[0] = {} } const identityKey = requiredAuthenticatedIdentityKey(req) - if (params[0].identityKey && params[0].identityKey !== identityKey) { - throw new WERR_UNAUTHORIZED('identityKey does not match authentication') - } + if (params[0].identityKey && params[0].identityKey !== identityKey) { throw new WERR_UNAUTHORIZED('identityKey does not match authentication') } // console.log('looking up user with identityKey:', identityKey) const { user } = await this.storage.findOrInsertUser(identityKey) params[0].reqAuthUserId = user.userId @@ -720,14 +740,14 @@ export class StorageServer { server: any - public start(): void { + public start (): void { this.server = this.app.listen(this.port, () => { console.log(`WalletStorageServer listening at http://localhost:${this.port}`) }) configureHttpServer(this.server, 'WALLET_STORAGE', this.httpPolicy) } - public async close(): Promise { + public async close (): Promise { if (this.server) { await this.server.close(() => { // console.log('WalletStorageServer closed') @@ -736,9 +756,7 @@ export class StorageServer { } /** @see {@link validateDate} */ - validateDate(date: Date | string | number): Date { - return validateDate(date) - } + validateDate (date: Date | string | number): Date { return validateDate(date) } /** * Helper to force uniform behavior across database engines. diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts index ff9f8cc0d..d114d28db 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts @@ -68,4 +68,74 @@ describe('StorageClient telemetry', () => { expect(JSON.stringify(events)).not.toContain('userId') expect(JSON.stringify(events)).not.toContain('available') }) + + it('preserves caller logging while reporting remote and protocol failures', async () => { + const events: TelemetryEvent[] = [] + const client = new StorageClient( + {} as WalletInterface, + 'https://storage.example.test/rpc', + { + telemetry: { + sink: { capture: event => events.push(event) } + } + } + ) + const logger = { + indent: 2, + group: jest.fn(), + merge: jest.fn(), + groupEnd: jest.fn(), + error: jest.fn() + } + const params: unknown[] = [{ userId: 1 }, { logger }] + const fetch = jest.fn() + Reflect.set(client, 'authClient', { fetch }) + + fetch.mockResolvedValueOnce(new Response(JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { log: { logs: [] }, available: true } + }))) + await expect( + Reflect.get(client, 'rpcCall').call(client, 'isAvailable', params) + ).resolves.toMatchObject({ available: true }) + expect(logger.group).toHaveBeenCalled() + expect(logger.merge).toHaveBeenCalledWith({ logs: [] }) + expect(logger.groupEnd).toHaveBeenCalled() + expect((params[1] as { logger: unknown }).logger).toBe(logger) + + fetch.mockRejectedValueOnce(new Error('fetch unavailable')) + await expect( + Reflect.get(client, 'rpcCall').call(client, 'isAvailable', params) + ).rejects.toThrow('fetch unavailable') + expect((params[1] as { logger: unknown }).logger).toBe(logger) + + fetch.mockResolvedValueOnce(new Response('unavailable', { + status: 503, + statusText: 'Service Unavailable' + })) + await expect( + Reflect.get(client, 'rpcCall').call(client, 'isAvailable', params) + ).rejects.toThrow('network error 503') + + fetch.mockResolvedValueOnce(new Response(JSON.stringify({ + jsonrpc: '2.0', + id: 4, + error: { + code: 1, + message: 'remote failure', + name: 'Error' + } + }))) + await expect( + Reflect.get(client, 'rpcCall').call(client, 'isAvailable', params) + ).rejects.toThrow('remote failure') + + expect(events.filter(event => event.name === 'wallet.storage.rpc')).toHaveLength(4) + expect(events.filter(event => event.name === 'wallet.storage.rpc').slice(1)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ spanStatus: 'error' }) + ]) + ) + }) }) diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts index 7fcf0152f..aa79a0ac3 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts @@ -1,5 +1,5 @@ import { type Request, type Response } from 'express' -import { WalletLoggerInterface } from '@bsv/sdk' +import { TelemetryEvent, WalletLoggerInterface } from '@bsv/sdk' import { WalletLogger } from '../../../WalletLogger' import { SyncChunk } from '../../../sdk/WalletStorage.interfaces' import { StorageServer, WalletStorageServerOptions } from '../StorageServer' @@ -140,6 +140,102 @@ describe('StorageServer JSON-RPC boundary', () => { expect(consoleLog).toHaveBeenCalledWith(expect.stringContaining('trace-id')) }) + test('correlates the HTTP, authorization, handler, and RPC spans', async () => { + const events: TelemetryEvent[] = [] + let nextSpanId = 1 + const server = makeServer({}, { + logRpcRequests: true, + telemetry: { + sink: { capture: event => events.push(event) }, + spanIdFactory: () => (nextSpanId++).toString(16).padStart(16, '0') + } + }) + const captured = makeResponse() + const request = makeRequest({ + jsonrpc: '2.0', + method: 'getSettings', + params: [], + id: 8 + }, { + traceparent: '00-0123456789abcdef0123456789abcdef-fedcba9876543210-01' + }) + Reflect.get(server, 'telemetry').bindContext(request, { + traceId: '0123456789abcdef0123456789abcdef', + spanId: 'fedcba9876543210', + traceFlags: 1 + }) + + await invoke(server, 'handleRpcRequest', request, captured.response) + + const byName = new Map(events.map(event => [event.name, event])) + expect(byName.get('wallet.storage.rpc')).toMatchObject({ + traceId: '0123456789abcdef0123456789abcdef', + parentSpanId: 'fedcba9876543210', + spanStatus: 'ok', + attributes: { 'rpc.method': 'getSettings' } + }) + expect(byName.get('wallet.storage.authorize')?.parentSpanId).toBe( + byName.get('wallet.storage.rpc')?.spanId + ) + expect(byName.get('wallet.storage.handler')?.parentSpanId).toBe( + byName.get('wallet.storage.rpc')?.spanId + ) + expect(consoleLog).toHaveBeenCalledWith( + expect.stringContaining('0123456789abcdef0123456789abcdef') + ) + + const invalid = makeResponse() + await invoke(server, 'handleRpcRequest', makeRequest({ + jsonrpc: '1.0', + params: [], + id: 9 + }), invalid.response) + expect(events.find(event => event.attributes?.['rpc.method'] === 'invalid')).toBeDefined() + }) + + test.each([ + [204, true, 'finish', 'ok'], + [503, true, 'finish', 'error'], + [200, false, 'close', 'cancelled'] + ])( + 'records HTTP completion status %i on %s', + (statusCode, writableEnded, completionEvent, expectedStatus) => { + const events: TelemetryEvent[] = [] + const server = makeServer({}, { + telemetry: { sink: { capture: event => events.push(event) } } + }) + const listeners = new Map void>() + const response = { + statusCode, + writableEnded, + once: (name: string, callback: () => void) => { + listeners.set(name, callback) + } + } as unknown as Response + const request = makeRequest({}, { + 'content-length': '42', + traceparent: '00-0123456789abcdef0123456789abcdef-fedcba9876543210-01' + }) + const next = jest.fn() + + void invoke(server, 'traceHttpRequest', request, response, next) + listeners.get(completionEvent)?.() + listeners.get(completionEvent)?.() + + expect(next).toHaveBeenCalledTimes(1) + expect(events[0]).toMatchObject({ + name: 'wallet.storage.http.request', + traceId: '0123456789abcdef0123456789abcdef', + spanStatus: expectedStatus, + attributes: { + 'http.request.method': 'POST', + 'http.request.body_size': 42, + 'http.response.status_code': statusCode + } + }) + } + ) + test('returns protocol and method errors without invoking storage', async () => { const server = makeServer() const invalid = makeResponse() From d476a55b6585574f446f88737a920eace38acbc4 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 29 Jul 2026 19:53:02 -0700 Subject: [PATCH 6/9] test: close telemetry coverage gaps --- .../__tests/ExpressTransportHardening.test.ts | 10 +- .../src/telemetry/__tests__/Telemetry.test.ts | 50 +++ .../telemetry/__tests__/TraceContext.test.ts | 7 + .../__tests__/WalletInstrumentation.test.ts | 19 +- ...WalletPermissionsManager.callbacks.test.ts | 22 + .../src/__tests__/Wallet.telemetry.test.ts | 25 +- .../__tests/ChaintracksChainTracker.test.ts | 35 +- .../__test/StorageKnex.telemetry.test.ts | 4 +- .../GenerateChange/generateChangeSdk.test.ts | 25 +- .../src/storage/remoting/StorageClientBase.ts | 8 +- .../__test/StorageClient.telemetry.test.ts | 127 +++--- .../remoting/__test/StorageServerRpc.test.ts | 386 +++++++++--------- .../test/Wallet/action/actionBatch.test.ts | 15 +- 13 files changed, 412 insertions(+), 321 deletions(-) diff --git a/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts b/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts index 0d64bc135..82bab0a26 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts @@ -853,9 +853,11 @@ describe('ExpressTransport hardening', () => { allowUnauthenticated: true, telemetry }) - for (const [statusCode, writableEnded, eventName, status] of [ - [503, true, 'finish', 'error'], - [200, false, 'close', 'cancelled'] + for (const [statusCode, writableEnded, eventName, status, traceparent] of [ + [503, true, 'finish', 'error', 'x'.repeat(129)], + [200, true, 'finish', 'ok', undefined], + [200, true, 'close', 'ok', `00-0123456789abcdef0123456789abcdef-${'0'.repeat(16)}-01`], + [200, false, 'close', 'cancelled', undefined] ] as const) { const res = responseMock() res.statusCode = statusCode @@ -864,7 +866,7 @@ describe('ExpressTransport hardening', () => { { path: '/public', method: 'GET', - headers: {} + headers: traceparent === undefined ? {} : { traceparent } } as any, res, jest.fn() diff --git a/packages/sdk/src/telemetry/__tests__/Telemetry.test.ts b/packages/sdk/src/telemetry/__tests__/Telemetry.test.ts index 1dbd81712..d37f3b9ea 100644 --- a/packages/sdk/src/telemetry/__tests__/Telemetry.test.ts +++ b/packages/sdk/src/telemetry/__tests__/Telemetry.test.ts @@ -400,5 +400,55 @@ describe('Telemetry', () => { spanStatus: 'error', error: { message: 'synchronous failure' } }) + + const edgeEvents: TelemetryEvent[] = [] + const edgeTelemetry = new Telemetry({ + sink: { capture: event => edgeEvents.push(event) } + }) + edgeTelemetry.capture({ + name: 'invalid.span', + component: 'test', + spanId: '0'.repeat(16), + error: { name: '', message: '' } + }) + edgeTelemetry.capture({ + name: 'object.error', + component: 'test', + error: { message: 'object error' } + }) + edgeTelemetry.capture({ + name: 'primitive.error', + component: 'test', + error: 'primitive error' + }) + edgeTelemetry.startSpan('explicit.error', { component: 'test' }).end({ + status: 'error' + }) + edgeTelemetry.startSpan('inferred.error', { component: 'test' }).end({ + error: new Error('inferred error') + }) + expect(edgeEvents[0]).toMatchObject({ + error: { name: 'Error', message: 'Unknown error' } + }) + expect(edgeEvents[0].spanId).toBeUndefined() + expect(edgeEvents[1]).toMatchObject({ + error: { name: 'Error', message: 'object error' } + }) + expect(edgeEvents[2]).toMatchObject({ + error: { name: 'Error', message: 'primitive error' } + }) + + const performanceDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'performance') + try { + Object.defineProperty(globalThis, 'performance', { + configurable: true, + value: undefined + }) + expect(edgeTelemetry.monotonicClock()()).toBeGreaterThan(0) + } finally { + if (performanceDescriptor != null) { + Object.defineProperty(globalThis, 'performance', performanceDescriptor) + } + } }) }) diff --git a/packages/sdk/src/telemetry/__tests__/TraceContext.test.ts b/packages/sdk/src/telemetry/__tests__/TraceContext.test.ts index f7b0208cf..ec5a0f46d 100644 --- a/packages/sdk/src/telemetry/__tests__/TraceContext.test.ts +++ b/packages/sdk/src/telemetry/__tests__/TraceContext.test.ts @@ -8,6 +8,13 @@ describe('W3C trace context', () => { const value = formatTraceparent({ traceId, spanId, traceFlags: 1 }) expect(value).toBe(`00-${traceId}-${spanId}-01`) expect(parseTraceparent(value)).toEqual({ traceId, spanId, traceFlags: 1 }) + expect(formatTraceparent({ traceId, spanId })).toBe(`00-${traceId}-${spanId}-01`) + expect(formatTraceparent({ traceId, spanId, traceFlags: -1 })).toBe( + `00-${traceId}-${spanId}-00` + ) + expect(formatTraceparent({ traceId, spanId, traceFlags: 999 })).toBe( + `00-${traceId}-${spanId}-ff` + ) }) it.each([ diff --git a/packages/sdk/src/telemetry/__tests__/WalletInstrumentation.test.ts b/packages/sdk/src/telemetry/__tests__/WalletInstrumentation.test.ts index 96cc5bbdd..e09e1c1b4 100644 --- a/packages/sdk/src/telemetry/__tests__/WalletInstrumentation.test.ts +++ b/packages/sdk/src/telemetry/__tests__/WalletInstrumentation.test.ts @@ -29,9 +29,15 @@ describe('instrumentWallet', () => { attributes: method => ({ 'bridge.method': String(method) }) }) await instrumented.getVersion({}) + await instrumented.getVersion({}) - expect(getVersion).toHaveBeenCalledTimes(1) - expect(events.map(event => event.name)).toEqual(['wallet.inner', 'wallet.call.getVersion']) + expect(getVersion).toHaveBeenCalledTimes(2) + expect(events.map(event => event.name)).toEqual([ + 'wallet.inner', + 'wallet.call.getVersion', + 'wallet.inner', + 'wallet.call.getVersion' + ]) expect(events[0]).toMatchObject({ traceId: events[1].traceId, parentSpanId: events[1].spanId @@ -65,4 +71,13 @@ describe('instrumentWallet', () => { expect(instrumented.value).toBe(7) expect(instrumented.customMethod()).toBe(7) }) + + it('preserves non-function values assigned to wallet method names', () => { + const wallet = { getVersion: 'temporarily unavailable' } as unknown as WalletInterface + const instrumented = instrumentWallet(wallet, { + sink: { capture: () => {} } + }) + + expect(Reflect.get(instrumented, 'getVersion')).toBe('temporarily unavailable') + }) }) diff --git a/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.callbacks.test.ts b/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.callbacks.test.ts index d083e4cd0..9c591d259 100644 --- a/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.callbacks.test.ts +++ b/packages/wallet/wallet-toolbox/src/__tests/WalletPermissionsManager.callbacks.test.ts @@ -244,6 +244,28 @@ describe('WalletPermissionsManager - Callbacks & Event Handling', () => { expect(JSON.stringify(events)).not.toContain('private-protocol-name') expect(JSON.stringify(events)).not.toContain('private-key-id') expect(JSON.stringify(events)).not.toContain(requestID) + + const grantedPromise = manager.createSignature( + { + protocolID: [1, 'second-private-protocol'], + keyID: 'second-private-key', + data: [0x03, 0x04], + privileged: false + }, + 'private-origin.example.com' + ) + await new Promise(resolve => setTimeout(resolve, 10)) + await manager.grantPermission({ + requestID, + expiry: 123456789, + ephemeral: true + }) + await expect(grantedPromise).resolves.toBeDefined() + expect( + events.find( + event => event.name === 'wallet.permission.request' && event.attributes?.['permission.granted'] === true + ) + ).toBeDefined() }) it('should resolve the original caller promise when requests are granted', async () => { diff --git a/packages/wallet/wallet-toolbox/src/__tests__/Wallet.telemetry.test.ts b/packages/wallet/wallet-toolbox/src/__tests__/Wallet.telemetry.test.ts index 120601601..cede18bd9 100644 --- a/packages/wallet/wallet-toolbox/src/__tests__/Wallet.telemetry.test.ts +++ b/packages/wallet/wallet-toolbox/src/__tests__/Wallet.telemetry.test.ts @@ -21,11 +21,7 @@ describe('Wallet validation telemetry', () => { const normalized = { limit: 10, offset: 0 } const validate = jest.fn(() => normalized) - const result = Reflect.get(wallet, 'validateAuthAndArgs').call( - wallet, - args, - validate - ) + const result = Reflect.get(wallet, 'validateAuthAndArgs').call(wallet, args, validate) expect(result).toEqual({ vargs: normalized, @@ -41,16 +37,15 @@ describe('Wallet validation telemetry', () => { attributes: { 'validation.result': 'ok' } }) + const primitiveResult = Reflect.get(wallet, 'validateAuthAndArgs').call(wallet, { offset: 0 }, () => 'normalized') + expect(primitiveResult.vargs).toBe('normalized') + expect(() => - Reflect.get(wallet, 'validateAuthAndArgs').call( - wallet, - { invalid: true }, - () => { - throw new Error('invalid arguments') - } - ) + Reflect.get(wallet, 'validateAuthAndArgs').call(wallet, { invalid: true }, () => { + throw new Error('invalid arguments') + }) ).toThrow('invalid arguments') - expect(events[1]).toMatchObject({ + expect(events[2]).toMatchObject({ name: 'wallet.validate_args', spanStatus: 'error', attributes: { 'validation.result': 'rejected' }, @@ -62,9 +57,7 @@ describe('Wallet validation telemetry', () => { const validate = jest.fn(() => 'validated') const disabled = walletWithTelemetry(new Telemetry()) - expect( - Reflect.get(disabled, 'validateAuthAndArgs').call(disabled, null, validate) - ).toEqual({ + expect(Reflect.get(disabled, 'validateAuthAndArgs').call(disabled, null, validate)).toEqual({ vargs: 'validated', auth: { identityKey: 'wallet-identity' } }) diff --git a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts index 6fef5a145..3a7cbfaef 100644 --- a/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts +++ b/packages/wallet/wallet-toolbox/src/services/chaintracker/__tests/ChaintracksChainTracker.test.ts @@ -20,7 +20,7 @@ const HEADER_877599 = { const realFetch = global.fetch beforeAll(() => { global.fetch = jest.fn(async (input: any, init?: any) => { - const url = typeof input === 'string' ? input : input?.url ?? '' + const url = typeof input === 'string' ? input : (input?.url ?? '') if (url.includes('chaintracks.babbage.systems/getPresentHeight')) { return jsonResponse({ status: 'success', value: 950000 }) } @@ -51,11 +51,7 @@ describe('ChaintracksChaintracker tests', () => { }) test('retries transient header lookup failures', async () => { - const chaintracks = makeChaintracksClient([ - new Error('temporary chaintracks failure'), - undefined, - HEADER_877599 - ]) + const chaintracks = makeChaintracksClient([new Error('temporary chaintracks failure'), undefined, HEADER_877599]) const tracker = new ChaintracksChainTracker('main', chaintracks, { maxRetries: 3, retryDelayMs: 0 }) await expect(tracker.isValidRootForHeight(HEADER_877599.merkleRoot, HEADER_877599.height)).resolves.toBe(true) @@ -63,13 +59,12 @@ describe('ChaintracksChaintracker tests', () => { }) test('throws the final chaintracks lookup error after retries', async () => { - const chaintracks = makeChaintracksClient([ - new Error('first failure'), - new Error('final failure') - ]) + const chaintracks = makeChaintracksClient([new Error('first failure'), new Error('final failure')]) const tracker = new ChaintracksChainTracker('main', chaintracks, { maxRetries: 2, retryDelayMs: 0 }) - await expect(tracker.isValidRootForHeight(HEADER_877599.merkleRoot, HEADER_877599.height)).rejects.toThrow('final failure') + await expect(tracker.isValidRootForHeight(HEADER_877599.merkleRoot, HEADER_877599.height)).rejects.toThrow( + 'final failure' + ) expect(chaintracks.findHeaderForHeight).toHaveBeenCalledTimes(2) }) @@ -89,12 +84,9 @@ describe('ChaintracksChaintracker tests', () => { } }) - await expect( - tracker.isValidRootForHeight(HEADER_877599.merkleRoot, HEADER_877599.height) - ).resolves.toBe(true) - await expect( - tracker.isValidRootForHeight(HEADER_877599.merkleRoot, HEADER_877599.height) - ).resolves.toBe(true) + await expect(tracker.isValidRootForHeight(HEADER_877599.merkleRoot, HEADER_877599.height)).resolves.toBe(true) + await expect(tracker.isValidRootForHeight(HEADER_877599.merkleRoot, HEADER_877599.height)).resolves.toBe(true) + await expect(tracker.currentHeight()).resolves.toBe(950000) const attempts = events.filter(event => event.name === 'wallet.chaintracks.find_header') const validations = events.filter(event => event.name === 'wallet.chaintracks.validate_root') @@ -109,12 +101,15 @@ describe('ChaintracksChaintracker tests', () => { 'chaintracks.cache_hit': true, 'chaintracks.valid': true }) + expect(events.find(event => event.name === 'wallet.chaintracks.current_height')).toMatchObject({ + spanStatus: 'ok' + }) expect(JSON.stringify(events)).not.toContain(HEADER_877599.merkleRoot) expect(JSON.stringify(events)).not.toContain(HEADER_877599.previousHash) }) }) -async function testChaintracksChaintracker (chain: sdk.Chain) { +async function testChaintracksChaintracker(chain: sdk.Chain) { const tracker = new ChaintracksChainTracker(chain) const height = await tracker.currentHeight() expect(height).toBeGreaterThan(877598) @@ -130,11 +125,11 @@ async function testChaintracksChaintracker (chain: sdk.Chain) { expect(okTest).toBe(chain === 'test') } -function jsonResponse (body: unknown): any { +function jsonResponse(body: unknown): any { return { ok: true, status: 200, json: async () => body } } -function makeChaintracksClient (responses: Array): any { +function makeChaintracksClient(responses: Array): any { return { getPresentHeight: jest.fn(async () => 950000), findHeaderForHeight: jest.fn(async () => { diff --git a/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnex.telemetry.test.ts b/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnex.telemetry.test.ts index ba3a6531d..f61d643c7 100644 --- a/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnex.telemetry.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/__test/StorageKnex.telemetry.test.ts @@ -70,7 +70,8 @@ describe('StorageKnex telemetry', () => { it.each([ ['mysql2', 'mysql'], ['pg', 'postgresql'], - ['custom-adapter', 'unknown'] + ['custom-adapter', 'unknown'], + [undefined, 'unknown'] ])('normalizes the %s client without depending on query payloads', async (client, system) => { const events: TelemetryEvent[] = [] const knex = new EventEmitter() as any @@ -86,6 +87,7 @@ describe('StorageKnex telemetry', () => { }) knex.emit('query', { method: 'select' }) + knex.emit('query-response', undefined, {}) knex.emit('query-response', undefined, { __knexQueryUid: 'not-started' }) knex.emit('query', { __knexQueryUid: 'query', method: undefined }) knex.emit('query-response', undefined, { __knexQueryUid: 'query' }) diff --git a/packages/wallet/wallet-toolbox/src/storage/methods/__test/GenerateChange/generateChangeSdk.test.ts b/packages/wallet/wallet-toolbox/src/storage/methods/__test/GenerateChange/generateChangeSdk.test.ts index d75ce61f0..431fd9689 100644 --- a/packages/wallet/wallet-toolbox/src/storage/methods/__test/GenerateChange/generateChangeSdk.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/methods/__test/GenerateChange/generateChangeSdk.test.ts @@ -125,6 +125,10 @@ describe('generateChange tests', () => { }) test('2 WERR_INSUFFICIENT_FUNDS', async () => { + const events: any[] = [] + const telemetry = new Telemetry({ + sink: { capture: event => events.push(event) } + }) const params: GenerateChangeSdkParams = { ...defParams, fixedOutputs: [ @@ -137,10 +141,10 @@ describe('generateChange tests', () => { const { allocateChangeInput, releaseChangeInput } = generateChangeSdkMakeStorage(availableChange) - expectToThrowWERR( - sdk.WERR_INSUFFICIENT_FUNDS, - async () => await generateChangeSdk(params, allocateChangeInput, releaseChangeInput) - ) + await expect( + generateChangeSdk(params, allocateChangeInput, releaseChangeInput, undefined, telemetry) + ).rejects.toBeInstanceOf(sdk.WERR_INSUFFICIENT_FUNDS) + expect(events.some(event => event.name === 'wallet.storage.generate_change.release')).toBe(true) }) test('2a WERR_INSUFFICIENT_FUNDS no inputs', async () => { @@ -1177,16 +1181,9 @@ describe('generateChange tests', () => { { satoshis: 2, lockingScriptLength: 25 } ] } - const { allocateChangeInput, releaseChangeInput } = - generateChangeSdkMakeStorage([...defAvailableChange()]) - - await generateChangeSdk( - params, - allocateChangeInput, - releaseChangeInput, - undefined, - telemetry - ) + const { allocateChangeInput, releaseChangeInput } = generateChangeSdkMakeStorage([...defAvailableChange()]) + + await generateChangeSdk(params, allocateChangeInput, releaseChangeInput, undefined, telemetry) const total = events.find(event => event.name === 'wallet.storage.generate_change') const allocation = events.find(event => event.name === 'wallet.storage.generate_change.allocate') diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts index 397931a3e..4731b1e4e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts @@ -316,7 +316,8 @@ export abstract class StorageClientBase implements WalletStorageProvider { async putActionBatchPack(_auth: AuthId, args: PutActionBatchPackArgs): Promise { const available = new Set(supportedActionBatchPackEncodings()) - const encoding = (args.preferredEncodings ?? ['identity']).find(candidate => available.has(candidate)) ?? 'identity' + const encoding = (args.preferredEncodings ?? ['identity']) + .find(candidate => available.has(candidate)) ?? 'identity' const frameLength = actionBatchPackLength(args.items) let body = await compressActionBatchPackItems(args.items, encoding, args.maxPackBytes, args.maxItems) let transmittedEncoding = encoding @@ -346,7 +347,10 @@ export abstract class StorageClientBase implements WalletStorageProvider { return await this.rpcCall('commitActionBatch', [auth, manifest]) } - async commitActionBatchByDigest(auth: AuthId, args: CommitActionBatchByDigestArgs): Promise { + async commitActionBatchByDigest( + auth: AuthId, + args: CommitActionBatchByDigestArgs + ): Promise { return await this.rpcCall('commitActionBatchByDigest', [auth, args]) } diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts index d114d28db..8c57571c5 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts @@ -6,19 +6,15 @@ describe('StorageClient telemetry', () => { const events: TelemetryEvent[] = [] let nextSpanId = 1 let requestInit: RequestInit | undefined - const client = new StorageClient( - {} as WalletInterface, - 'https://storage.example.test/rpc', - { - telemetry: { - sink: { - capture: event => events.push(event) - }, - traceIdFactory: () => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - spanIdFactory: () => (nextSpanId++).toString(16).padStart(16, '0') - } + const client = new StorageClient({} as WalletInterface, 'https://storage.example.test/rpc', { + telemetry: { + sink: { + capture: event => events.push(event) + }, + traceIdFactory: () => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanIdFactory: () => (nextSpanId++).toString(16).padStart(16, '0') } - ) + }) Reflect.set(client, 'authClient', { fetch: jest.fn(async (_url: string, init: RequestInit) => { requestInit = init @@ -36,24 +32,22 @@ describe('StorageClient telemetry', () => { }) }) - const result = await Reflect.get(client, 'rpcCall').call( - client, - 'isAvailable', - [{ userId: 1 }] - ) + const result = await Reflect.get(client, 'rpcCall').call(client, 'isAvailable', [{ userId: 1 }]) expect(result).toEqual({ available: true }) expect((requestInit?.headers as Record).traceparent).toMatch( /^00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-[0-9a-f]{16}-01$/ ) const byName = new Map(events.map(event => [event.name, event])) - expect([ - 'wallet.storage.request.serialize', - 'wallet.storage.http', - 'wallet.storage.response.read', - 'wallet.storage.response.parse', - 'wallet.storage.rpc' - ].every(name => byName.has(name))).toBe(true) + expect( + [ + 'wallet.storage.request.serialize', + 'wallet.storage.http', + 'wallet.storage.response.read', + 'wallet.storage.response.parse', + 'wallet.storage.rpc' + ].every(name => byName.has(name)) + ).toBe(true) const rpc = byName.get('wallet.storage.rpc')! expect(rpc).toMatchObject({ type: 'span', @@ -71,15 +65,11 @@ describe('StorageClient telemetry', () => { it('preserves caller logging while reporting remote and protocol failures', async () => { const events: TelemetryEvent[] = [] - const client = new StorageClient( - {} as WalletInterface, - 'https://storage.example.test/rpc', - { - telemetry: { - sink: { capture: event => events.push(event) } - } + const client = new StorageClient({} as WalletInterface, 'https://storage.example.test/rpc', { + telemetry: { + sink: { capture: event => events.push(event) } } - ) + }) const logger = { indent: 2, group: jest.fn(), @@ -91,51 +81,58 @@ describe('StorageClient telemetry', () => { const fetch = jest.fn() Reflect.set(client, 'authClient', { fetch }) - fetch.mockResolvedValueOnce(new Response(JSON.stringify({ - jsonrpc: '2.0', - id: 1, - result: { log: { logs: [] }, available: true } - }))) - await expect( - Reflect.get(client, 'rpcCall').call(client, 'isAvailable', params) - ).resolves.toMatchObject({ available: true }) + fetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { log: { logs: [] }, available: true } + }) + ) + ) + await expect(Reflect.get(client, 'rpcCall').call(client, 'isAvailable', params)).resolves.toMatchObject({ + available: true + }) expect(logger.group).toHaveBeenCalled() expect(logger.merge).toHaveBeenCalledWith({ logs: [] }) expect(logger.groupEnd).toHaveBeenCalled() expect((params[1] as { logger: unknown }).logger).toBe(logger) + ;(logger as { indent?: number }).indent = undefined fetch.mockRejectedValueOnce(new Error('fetch unavailable')) - await expect( - Reflect.get(client, 'rpcCall').call(client, 'isAvailable', params) - ).rejects.toThrow('fetch unavailable') + await expect(Reflect.get(client, 'rpcCall').call(client, 'isAvailable', params)).rejects.toThrow( + 'fetch unavailable' + ) expect((params[1] as { logger: unknown }).logger).toBe(logger) - fetch.mockResolvedValueOnce(new Response('unavailable', { - status: 503, - statusText: 'Service Unavailable' - })) - await expect( - Reflect.get(client, 'rpcCall').call(client, 'isAvailable', params) - ).rejects.toThrow('network error 503') + fetch.mockResolvedValueOnce( + new Response('unavailable', { + status: 503, + statusText: 'Service Unavailable' + }) + ) + await expect(Reflect.get(client, 'rpcCall').call(client, 'isAvailable', params)).rejects.toThrow( + 'network error 503' + ) - fetch.mockResolvedValueOnce(new Response(JSON.stringify({ - jsonrpc: '2.0', - id: 4, - error: { - code: 1, - message: 'remote failure', - name: 'Error' - } - }))) - await expect( - Reflect.get(client, 'rpcCall').call(client, 'isAvailable', params) - ).rejects.toThrow('remote failure') + fetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: 4, + error: { + code: 1, + message: 'remote failure', + name: 'Error' + } + }) + ) + ) + await expect(Reflect.get(client, 'rpcCall').call(client, 'isAvailable', params)).rejects.toThrow('remote failure') expect(events.filter(event => event.name === 'wallet.storage.rpc')).toHaveLength(4) expect(events.filter(event => event.name === 'wallet.storage.rpc').slice(1)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ spanStatus: 'error' }) - ]) + expect.arrayContaining([expect.objectContaining({ spanStatus: 'error' })]) ) }) }) diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts index aa79a0ac3..addd1dd34 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageServerRpc.test.ts @@ -3,11 +3,7 @@ import { TelemetryEvent, WalletLoggerInterface } from '@bsv/sdk' import { WalletLogger } from '../../../WalletLogger' import { SyncChunk } from '../../../sdk/WalletStorage.interfaces' import { StorageServer, WalletStorageServerOptions } from '../StorageServer' -import { - BINARY_ENCODING, - BINARY_ENCODING_HEADER, - BINARY_REQUEST_ENCODING_HEADER -} from '../BinaryJson' +import { BINARY_ENCODING, BINARY_ENCODING_HEADER, BINARY_REQUEST_ENCODING_HEADER } from '../BinaryJson' interface CapturedResponse { body?: any @@ -16,7 +12,7 @@ interface CapturedResponse { statusCode: number } -function makeResponse (): CapturedResponse { +function makeResponse(): CapturedResponse { const captured: CapturedResponse = { headers: {}, response: undefined as unknown as Response, @@ -40,7 +36,7 @@ function makeResponse (): CapturedResponse { return captured } -function makeRequest ( +function makeRequest( body: unknown, headers: Record = {}, identityKey: string = 'alice' @@ -59,7 +55,7 @@ function makeRequest ( } as unknown as Request } -function makeServer ( +function makeServer( storageOverrides: Record = {}, optionsOverrides: Partial = {} ): StorageServer { @@ -89,8 +85,8 @@ function makeServer ( }) } -async function invoke (server: StorageServer, method: string, ...args: any[]): Promise { - return await Reflect.get(server, method).call(server, ...args) as T +async function invoke(server: StorageServer, method: string, ...args: any[]): Promise { + return (await Reflect.get(server, method).call(server, ...args)) as T } const emptyChunk: SyncChunk = { @@ -116,16 +112,19 @@ describe('StorageServer JSON-RPC boundary', () => { test('negotiates binary JSON, records trace context, and dispatches a valid RPC', async () => { const server = makeServer() const captured = makeResponse() - const request = makeRequest({ - jsonrpc: '2.0', - method: 'getSettings', - params: [], - id: 1 - }, { - [BINARY_ENCODING_HEADER]: BINARY_ENCODING, - [BINARY_REQUEST_ENCODING_HEADER]: BINARY_ENCODING, - 'X-Cloud-Trace-Context': 'trace-id/123' - }) + const request = makeRequest( + { + jsonrpc: '2.0', + method: 'getSettings', + params: [], + id: 1 + }, + { + [BINARY_ENCODING_HEADER]: BINARY_ENCODING, + [BINARY_REQUEST_ENCODING_HEADER]: BINARY_ENCODING, + 'X-Cloud-Trace-Context': 'trace-id/123' + } + ) await invoke(server, 'handleRpcRequest', request, captured.response) @@ -143,22 +142,28 @@ describe('StorageServer JSON-RPC boundary', () => { test('correlates the HTTP, authorization, handler, and RPC spans', async () => { const events: TelemetryEvent[] = [] let nextSpanId = 1 - const server = makeServer({}, { - logRpcRequests: true, - telemetry: { - sink: { capture: event => events.push(event) }, - spanIdFactory: () => (nextSpanId++).toString(16).padStart(16, '0') + const server = makeServer( + {}, + { + logRpcRequests: true, + telemetry: { + sink: { capture: event => events.push(event) }, + spanIdFactory: () => (nextSpanId++).toString(16).padStart(16, '0') + } } - }) + ) const captured = makeResponse() - const request = makeRequest({ - jsonrpc: '2.0', - method: 'getSettings', - params: [], - id: 8 - }, { - traceparent: '00-0123456789abcdef0123456789abcdef-fedcba9876543210-01' - }) + const request = makeRequest( + { + jsonrpc: '2.0', + method: 'getSettings', + params: [], + id: 8 + }, + { + traceparent: '00-0123456789abcdef0123456789abcdef-fedcba9876543210-01' + } + ) Reflect.get(server, 'telemetry').bindContext(request, { traceId: '0123456789abcdef0123456789abcdef', spanId: 'fedcba9876543210', @@ -174,98 +179,116 @@ describe('StorageServer JSON-RPC boundary', () => { spanStatus: 'ok', attributes: { 'rpc.method': 'getSettings' } }) - expect(byName.get('wallet.storage.authorize')?.parentSpanId).toBe( - byName.get('wallet.storage.rpc')?.spanId - ) - expect(byName.get('wallet.storage.handler')?.parentSpanId).toBe( - byName.get('wallet.storage.rpc')?.spanId - ) - expect(consoleLog).toHaveBeenCalledWith( - expect.stringContaining('0123456789abcdef0123456789abcdef') - ) + expect(byName.get('wallet.storage.authorize')?.parentSpanId).toBe(byName.get('wallet.storage.rpc')?.spanId) + expect(byName.get('wallet.storage.handler')?.parentSpanId).toBe(byName.get('wallet.storage.rpc')?.spanId) + expect(consoleLog).toHaveBeenCalledWith(expect.stringContaining('0123456789abcdef0123456789abcdef')) const invalid = makeResponse() - await invoke(server, 'handleRpcRequest', makeRequest({ - jsonrpc: '1.0', - params: [], - id: 9 - }), invalid.response) + await invoke( + server, + 'handleRpcRequest', + makeRequest({ + jsonrpc: '1.0', + params: [], + id: 9 + }), + invalid.response + ) expect(events.find(event => event.attributes?.['rpc.method'] === 'invalid')).toBeDefined() }) test.each([ [204, true, 'finish', 'ok'], [503, true, 'finish', 'error'], + [200, true, 'close', 'ok'], [200, false, 'close', 'cancelled'] - ])( - 'records HTTP completion status %i on %s', - (statusCode, writableEnded, completionEvent, expectedStatus) => { - const events: TelemetryEvent[] = [] - const server = makeServer({}, { + ])('records HTTP completion status %i on %s', (statusCode, writableEnded, completionEvent, expectedStatus) => { + const events: TelemetryEvent[] = [] + const server = makeServer( + {}, + { telemetry: { sink: { capture: event => events.push(event) } } - }) - const listeners = new Map void>() - const response = { - statusCode, - writableEnded, - once: (name: string, callback: () => void) => { - listeners.set(name, callback) - } - } as unknown as Response - const request = makeRequest({}, { + } + ) + const listeners = new Map void>() + const response = { + statusCode, + writableEnded, + once: (name: string, callback: () => void) => { + listeners.set(name, callback) + } + } as unknown as Response + const request = makeRequest( + {}, + { 'content-length': '42', traceparent: '00-0123456789abcdef0123456789abcdef-fedcba9876543210-01' - }) - const next = jest.fn() - - void invoke(server, 'traceHttpRequest', request, response, next) - listeners.get(completionEvent)?.() - listeners.get(completionEvent)?.() - - expect(next).toHaveBeenCalledTimes(1) - expect(events[0]).toMatchObject({ - name: 'wallet.storage.http.request', - traceId: '0123456789abcdef0123456789abcdef', - spanStatus: expectedStatus, - attributes: { - 'http.request.method': 'POST', - 'http.request.body_size': 42, - 'http.response.status_code': statusCode - } - }) - } - ) + } + ) + const next = jest.fn() + + void invoke(server, 'traceHttpRequest', request, response, next) + listeners.get(completionEvent)?.() + listeners.get(completionEvent)?.() + + expect(next).toHaveBeenCalledTimes(1) + expect(events[0]).toMatchObject({ + name: 'wallet.storage.http.request', + traceId: '0123456789abcdef0123456789abcdef', + spanStatus: expectedStatus, + attributes: { + 'http.request.method': 'POST', + 'http.request.body_size': 42, + 'http.response.status_code': statusCode + } + }) + }) test('returns protocol and method errors without invoking storage', async () => { const server = makeServer() const invalid = makeResponse() - await invoke(server, 'handleRpcRequest', makeRequest({ - jsonrpc: '1.0', - params: [], - id: 2 - }), invalid.response) + await invoke( + server, + 'handleRpcRequest', + makeRequest({ + jsonrpc: '1.0', + params: [], + id: 2 + }), + invalid.response + ) expect(invalid.statusCode).toBe(400) expect(invalid.body).toEqual({ error: { code: -32600, message: 'Invalid Request' } }) const unknown = makeResponse() - await invoke(server, 'handleRpcRequest', makeRequest({ - jsonrpc: '2.0', - method: 'notPublic', - params: [], - id: 3 - }), unknown.response) + await invoke( + server, + 'handleRpcRequest', + makeRequest({ + jsonrpc: '2.0', + method: 'notPublic', + params: [], + id: 3 + }), + unknown.response + ) expect(unknown.statusCode).toBe(400) expect(unknown.body).toMatchObject({ error: { code: -32601, message: 'Method not found: notPublic' } }) const missingHandler = makeResponse() - await invoke(makeServer({ adminStats: undefined }), 'handleRpcRequest', makeRequest({ - jsonrpc: '2.0', - method: 'adminStats', - params: ['alice'], - id: 4 - }), missingHandler.response) + await invoke( + makeServer({ adminStats: undefined }), + 'handleRpcRequest', + makeRequest({ + jsonrpc: '2.0', + method: 'adminStats', + params: ['alice'], + id: 4 + }), + missingHandler.response + ) expect(missingHandler.statusCode).toBe(400) expect(missingHandler.body).toMatchObject({ error: { code: -32601, message: 'Method not found: adminStats' } @@ -273,21 +296,29 @@ describe('StorageServer JSON-RPC boundary', () => { }) test('normalizes storage failures into JSON-RPC wallet errors', async () => { - const server = makeServer({ - getSettings: jest.fn(() => { - throw new Error('storage failed') - }) - }, { - makeLogger: () => new WalletLogger() - }) + const server = makeServer( + { + getSettings: jest.fn(() => { + throw new Error('storage failed') + }) + }, + { + makeLogger: () => new WalletLogger() + } + ) const captured = makeResponse() - await invoke(server, 'handleRpcRequest', makeRequest({ - jsonrpc: '2.0', - method: 'getSettings', - params: [{ userId: 7 }, {}], - id: 5 - }), captured.response) + await invoke( + server, + 'handleRpcRequest', + makeRequest({ + jsonrpc: '2.0', + method: 'getSettings', + params: [{ userId: 7 }, {}], + id: 5 + }), + captured.response + ) expect(captured.statusCode).toBe(200) expect(captured.body).toMatchObject({ @@ -305,23 +336,16 @@ describe('StorageServer JSON-RPC boundary', () => { params: [], id: 6 } - const requests = [ - makeRequest(body, {}, 'unknown'), - makeRequest(body, {}, ' '), - makeRequest(body) - ] + const requests = [makeRequest(body, {}, 'unknown'), makeRequest(body, {}, ' '), makeRequest(body)] Reflect.set(requests[2], 'auth', { identityKey: null }) const missingAuth = makeRequest(body) Reflect.deleteProperty(missingAuth, 'auth') requests.push(missingAuth) for (const request of requests) { - await expect(invoke( - server, - 'handleRpcRequest', - request, - makeResponse().response - )).rejects.toThrow('authenticated request identity is required') + await expect(invoke(server, 'handleRpcRequest', request, makeResponse().response)).rejects.toThrow( + 'authenticated request identity is required' + ) } }) @@ -335,16 +359,19 @@ describe('StorageServer JSON-RPC boundary', () => { const next = jest.fn() middleware( - makeRequest({ - jsonrpc: '2.0', - method: 'getSettings', - params: [], - id: 7 - }, { - 'content-length': '42', - 'content-type': 'application/json', - 'X-Cloud-Trace-Context': ['first-trace/123', 'second-trace/456'] - }), + makeRequest( + { + jsonrpc: '2.0', + method: 'getSettings', + params: [], + id: 7 + }, + { + 'content-length': '42', + 'content-type': 'application/json', + 'X-Cloud-Trace-Context': ['first-trace/123', 'second-trace/456'] + } + ), makeResponse().response, next ) @@ -363,72 +390,43 @@ describe('StorageServer JSON-RPC boundary', () => { await expect(invoke(server, 'authorizeRpcCall', 'destroy', [], request, destroyLog)).resolves.toBe(false) expect(destroyLog).toMatchObject({ comment: 'IGNORED' }) await expect(invoke(server, 'authorizeRpcCall', 'getSettings', [], request)).resolves.toBe(true) - await expect(invoke( - server, - 'authorizeRpcCall', - 'findOrInsertUser', - ['mallory'], - request - )).rejects.toThrow('authenticated user') - await expect(invoke( - server, - 'authorizeRpcCall', - 'adminStats', - ['mallory'], - request - )).rejects.toThrow('authenticated admin user') - await expect(invoke( - makeServer(), - 'authorizeRpcCall', - 'adminStats', - ['alice'], - request - )).rejects.toThrow('admin user') - await expect(invoke( - server, - 'authorizeRpcCall', - 'adminStats', - ['alice'], - request - )).resolves.toBe(true) + await expect(invoke(server, 'authorizeRpcCall', 'findOrInsertUser', ['mallory'], request)).rejects.toThrow( + 'authenticated user' + ) + await expect(invoke(server, 'authorizeRpcCall', 'adminStats', ['mallory'], request)).rejects.toThrow( + 'authenticated admin user' + ) + await expect(invoke(makeServer(), 'authorizeRpcCall', 'adminStats', ['alice'], request)).rejects.toThrow( + 'admin user' + ) + await expect(invoke(server, 'authorizeRpcCall', 'adminStats', ['alice'], request)).resolves.toBe(true) const syncParams: any[] = [{ identityKey: 'alice' }, { ...emptyChunk }] - await expect(invoke( - server, - 'authorizeRpcCall', - 'processSyncChunk', - syncParams, - request - )).resolves.toBe(true) + await expect(invoke(server, 'authorizeRpcCall', 'processSyncChunk', syncParams, request)).resolves.toBe(true) expect(syncParams[0].reqAuthUserId).toBe(7) const syncParamsWithoutClaim: any[] = [{}, { ...emptyChunk }] - await expect(invoke( - server, - 'authorizeRpcCall', - 'processSyncChunk', - syncParamsWithoutClaim, - request - )).resolves.toBe(true) + await expect(invoke(server, 'authorizeRpcCall', 'processSyncChunk', syncParamsWithoutClaim, request)).resolves.toBe( + true + ) expect(syncParamsWithoutClaim[0].reqAuthUserId).toBe(7) - await expect(invoke( - server, - 'authorizeRpcCall', - 'processSyncChunk', - [{ identityKey: 'mallory' }, { ...emptyChunk }], - request - )).rejects.toThrow('identityKey does not match authentication') + await expect( + invoke(server, 'authorizeRpcCall', 'processSyncChunk', [{ identityKey: 'mallory' }, { ...emptyChunk }], request) + ).rejects.toThrow('identityKey does not match authentication') }) test('propagates authenticated identity and nested logger output', async () => { - const server = makeServer({}, { - makeLogger: (): WalletLoggerInterface => { - const logger = new WalletLogger() - logger.isOrigin = false - return logger + const server = makeServer( + {}, + { + makeLogger: (): WalletLoggerInterface => { + const logger = new WalletLogger() + logger.isOrigin = false + return logger + } } - }) + ) const request = makeRequest({}, {}, 'alice') const params: any[] = [{ identityKey: 'alice', userId: 99 }, { logger: undefined }] @@ -456,12 +454,8 @@ describe('StorageServer JSON-RPC boundary', () => { isActive: true }) - await expect(invoke( - server, - 'authorizeStandardRpcCall', - 'abortAction', - [{ identityKey: 'mallory' }, {}], - request - )).rejects.toThrow('identityKey does not match authentication') + await expect( + invoke(server, 'authorizeStandardRpcCall', 'abortAction', [{ identityKey: 'mallory' }, {}], request) + ).rejects.toThrow('identityKey does not match authentication') }) }) diff --git a/packages/wallet/wallet-toolbox/test/Wallet/action/actionBatch.test.ts b/packages/wallet/wallet-toolbox/test/Wallet/action/actionBatch.test.ts index b4d3c6ea8..11f48f448 100644 --- a/packages/wallet/wallet-toolbox/test/Wallet/action/actionBatch.test.ts +++ b/packages/wallet/wallet-toolbox/test/Wallet/action/actionBatch.test.ts @@ -1,4 +1,4 @@ -import { Beef, CreateActionArgs, Transaction, Validation } from '@bsv/sdk' +import { Beef, CreateActionArgs, Telemetry, TelemetryEvent, Transaction, Validation } from '@bsv/sdk' import { Wallet } from '../../../src/Wallet' import { _tu, TestWalletNoSetup } from '../../utils/TestUtilsWalletStorage' import { actionBatchBlobDigest, actionBatchManifestDigest } from '../../../src/utility/actionBatchDigest' @@ -307,6 +307,14 @@ describe('in-memory action batch workspace', () => { }) test('two-step signing remains local until the batch commit', async () => { + const events: TelemetryEvent[] = [] + Reflect.set( + ctx.wallet, + 'telemetry', + new Telemetry({ + sink: { capture: event => events.push(event) } + }) + ) const legacyCreate = jest.spyOn(ctx.storage, 'createAction') const legacyProcess = jest.spyOn(ctx.storage, 'processAction') const commit = jest.spyOn(ctx.storage, 'commitActionBatch') @@ -316,6 +324,8 @@ describe('in-memory action batch workspace', () => { options: { ...actionArgs().options, signAndProcess: false } }) expect(created.signableTransaction).toBeDefined() + const pending = ctx.wallet.pendingSignActions[created.signableTransaction!.reference] + pending.dcr.inputBeef = Array.from(pending.dcr.inputBeef as Uint8Array) const signed = await ctx.wallet.signAction({ reference: created.signableTransaction!.reference, spends: {}, @@ -328,6 +338,9 @@ describe('in-memory action batch workspace', () => { await ctx.wallet.createAction({ description: 'Commit two-step batch', options: { sendWith: [signed.txid!] } }) expect(commit).toHaveBeenCalledTimes(1) expect(commit.mock.calls[0][0].actions[0].plan.inputs.every(input => input.sourceTransaction == null)).toBe(true) + expect(events.some(event => event.name === 'wallet.create_action' && event.spanStatus === 'ok')).toBe(true) + expect(events.some(event => event.name === 'wallet.sign_action' && event.spanStatus === 'ok')).toBe(true) + expect(events.some(event => event.name === 'wallet.crypto.transaction_sign')).toBe(true) }) test('an open batch does not capture a legacy pending signAction', async () => { From 0f61e2ebf79b1dcae9feee0ee78c716fd9cbecb1 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 29 Jul 2026 20:02:43 -0700 Subject: [PATCH 7/9] fix: align telemetry with latest main --- packages/sdk/src/telemetry/Telemetry.ts | 4 +- .../__tests/Services.telemetry.test.ts | 12 +- .../__tests__/signingTelemetry.test.ts | 12 +- .../src/storage/remoting/StorageClient.ts | 19 +- .../src/storage/remoting/StorageClientBase.ts | 8 +- .../src/storage/remoting/StorageServer.ts | 282 ++++++++---------- .../__test/StorageClient.telemetry.test.ts | 4 +- 7 files changed, 161 insertions(+), 180 deletions(-) diff --git a/packages/sdk/src/telemetry/Telemetry.ts b/packages/sdk/src/telemetry/Telemetry.ts index 066256979..3362e6d83 100644 --- a/packages/sdk/src/telemetry/Telemetry.ts +++ b/packages/sdk/src/telemetry/Telemetry.ts @@ -606,8 +606,8 @@ export class Telemetry { throw error } - if (result != null && typeof (result as PromiseLike).then === 'function') { - return Promise.resolve(result as PromiseLike).then( + if (result != null && typeof (result as unknown as PromiseLike).then === 'function') { + return Promise.resolve(result as unknown as PromiseLike).then( value => { span.end() return value diff --git a/packages/wallet/wallet-toolbox/src/services/__tests/Services.telemetry.test.ts b/packages/wallet/wallet-toolbox/src/services/__tests/Services.telemetry.test.ts index dc82bff12..b28f81854 100644 --- a/packages/wallet/wallet-toolbox/src/services/__tests/Services.telemetry.test.ts +++ b/packages/wallet/wallet-toolbox/src/services/__tests/Services.telemetry.test.ts @@ -45,11 +45,11 @@ describe('Services telemetry', () => { const attemptsSpans = events.filter(event => event.name === 'wallet.chaintracks.attempt') expect(requests).toHaveLength(4) expect(attemptsSpans).toHaveLength(6) - expect(attemptsSpans.every(event => - requests.some(request => - request.traceId === event.traceId && request.spanId === event.parentSpanId + expect( + attemptsSpans.every(event => + requests.some(request => request.traceId === event.traceId && request.spanId === event.parentSpanId) ) - )).toBe(true) + ).toBe(true) expect(requests.map(event => event.attributes?.['chaintracks.operation'])).toEqual([ 'retry_test', 'current_height', @@ -63,8 +63,6 @@ describe('Services telemetry', () => { options.chaintracks = {} as any const services = new Services(options) - await expect( - services.invokeChaintracksWithRetry(async () => 'untraced') - ).resolves.toBe('untraced') + await expect(services.invokeChaintracksWithRetry(async () => 'untraced')).resolves.toBe('untraced') }) }) diff --git a/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/signingTelemetry.test.ts b/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/signingTelemetry.test.ts index 419c4de21..a19ef57e9 100644 --- a/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/signingTelemetry.test.ts +++ b/packages/wallet/wallet-toolbox/src/signer/methods/__tests__/signingTelemetry.test.ts @@ -14,9 +14,9 @@ describe('signing telemetry', () => { }) } as any - await expect( - signAction(wallet, { identityKey: 'wallet' }, { reference: 'missing' }) - ).rejects.toThrow('recovery of out-of-session signAction') + await expect(signAction(wallet, { identityKey: 'wallet' }, { reference: 'missing' })).rejects.toThrow( + 'recovery of out-of-session signAction' + ) expect(events).toHaveLength(1) expect(events[0]).toMatchObject({ name: 'wallet.sign_action', @@ -24,9 +24,9 @@ describe('signing telemetry', () => { }) wallet.telemetry = new Telemetry() - await expect( - signAction(wallet, { identityKey: 'wallet' }, { reference: 'missing' }) - ).rejects.toThrow('recovery of out-of-session signAction') + await expect(signAction(wallet, { identityKey: 'wallet' }, { reference: 'missing' })).rejects.toThrow( + 'recovery of out-of-session signAction' + ) }) it('times transaction signing while retaining the disabled fast path', async () => { diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts index d7a8dbf91..d4177f5b5 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts @@ -1,12 +1,15 @@ -import { - WalletInterface, - WalletLoggerInterface -} from '@bsv/sdk' +import { WalletInterface, WalletLoggerInterface } from '@bsv/sdk' import { WalletErrorFromJson } from '../../sdk/WalletErrorFromJson' import { logWalletError } from '../../WalletLogger' import { formatTraceparent } from '../../utility/traceContext' import { StorageClientBase, type StorageClientOptions } from './StorageClientBase' -import { BINARY_ENCODING, BINARY_ENCODING_HEADER, BINARY_REQUEST_ENCODING_HEADER, parseJsonRpc, stringifyJsonRpc } from './BinaryJson' +import { + BINARY_ENCODING, + BINARY_ENCODING_HEADER, + BINARY_REQUEST_ENCODING_HEADER, + parseJsonRpc, + stringifyJsonRpc +} from './BinaryJson' /** * `StorageClient` implements the `WalletStorageProvider` interface which allows it to @@ -23,7 +26,7 @@ import { BINARY_ENCODING, BINARY_ENCODING_HEADER, BINARY_REQUEST_ENCODING_HEADER * For details of the API implemented, follow the "See also" link for the `WalletStorageProvider` interface. */ export class StorageClient extends StorageClientBase { - constructor (wallet: WalletInterface, endpointUrl: string, options: StorageClientOptions = {}) { + constructor(wallet: WalletInterface, endpointUrl: string, options: StorageClientOptions = {}) { super(wallet, endpointUrl, options) } @@ -34,8 +37,8 @@ export class StorageClient extends StorageClientBase { /** * Make a JSON-RPC call to the remote server. * @param method The WalletStorage method name to call. - * @param params The array of parameters to pass to the method in order. - */ + * @param params The array of parameters to pass to the method in order. + */ protected async rpcCall(method: string, params: unknown[]): Promise { return await this.traceRpcCall(method, params, async rpcSpan => { const requestOptions = diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts index 4731b1e4e..397931a3e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClientBase.ts @@ -316,8 +316,7 @@ export abstract class StorageClientBase implements WalletStorageProvider { async putActionBatchPack(_auth: AuthId, args: PutActionBatchPackArgs): Promise { const available = new Set(supportedActionBatchPackEncodings()) - const encoding = (args.preferredEncodings ?? ['identity']) - .find(candidate => available.has(candidate)) ?? 'identity' + const encoding = (args.preferredEncodings ?? ['identity']).find(candidate => available.has(candidate)) ?? 'identity' const frameLength = actionBatchPackLength(args.items) let body = await compressActionBatchPackItems(args.items, encoding, args.maxPackBytes, args.maxItems) let transmittedEncoding = encoding @@ -347,10 +346,7 @@ export abstract class StorageClientBase implements WalletStorageProvider { return await this.rpcCall('commitActionBatch', [auth, manifest]) } - async commitActionBatchByDigest( - auth: AuthId, - args: CommitActionBatchByDigestArgs - ): Promise { + async commitActionBatchByDigest(auth: AuthId, args: CommitActionBatchByDigestArgs): Promise { return await this.rpcCall('commitActionBatchByDigest', [auth, args]) } diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts index 9ad7c1c13..3f6f9f161 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageServer.ts @@ -14,11 +14,7 @@ import { WalletLoggerInterface } from '@bsv/sdk' import express, { Request, Response } from 'express' -import { - AuthMiddlewareOptions, - AuthRequest, - createAuthMiddleware -} from '@bsv/auth-express-middleware' +import { AuthMiddlewareOptions, AuthRequest, createAuthMiddleware } from '@bsv/auth-express-middleware' import { createPaymentMiddleware } from '@bsv/payment-express-middleware' import { Options as RateLimitOptions, rateLimit } from 'express-rate-limit' import { Wallet } from '../../Wallet' @@ -30,13 +26,14 @@ import { validateDate, validateEntity, validateEntities, validateSyncChunkEntiti import { WalletError } from '../../sdk/WalletError' import { logWalletError } from '../../WalletLogger' import { parseTraceparent } from '../../utility/traceContext' -import { BINARY_ENCODING, BINARY_ENCODING_HEADER, BINARY_REQUEST_ENCODING_HEADER, decodeBinaryJsonValue, stringifyJsonRpc } from './BinaryJson' import { - authenticatedIdentityKey, - configureTrustProxy, - rateLimitOptions, - TrustProxySetting -} from './RateLimitPolicy' + BINARY_ENCODING, + BINARY_ENCODING_HEADER, + BINARY_REQUEST_ENCODING_HEADER, + decodeBinaryJsonValue, + stringifyJsonRpc +} from './BinaryJson' +import { authenticatedIdentityKey, configureTrustProxy, rateLimitOptions, TrustProxySetting } from './RateLimitPolicy' import { bodyParserErrorHandler, concurrencyLimit, @@ -53,10 +50,7 @@ import { decompressActionBatchPack, supportedActionBatchPackEncodings } from '../../utility/actionBatchPack' -import { - ACTION_BATCH_MAX_PACK_BYTES, - ACTION_BATCH_MAX_PACK_ITEMS -} from '../methods/actionBatchBlobs' +import { ACTION_BATCH_MAX_PACK_BYTES, ACTION_BATCH_MAX_PACK_ITEMS } from '../methods/actionBatchBlobs' const storageRpcMethods = new Set([ 'abortAction', @@ -136,19 +130,15 @@ interface RpcDispatchResult { result?: unknown } -function requiredAuthenticatedIdentityKey (req: Request): string { +function requiredAuthenticatedIdentityKey(req: Request): string { const identityKey = (req as AuthRequest).auth?.identityKey - if ( - typeof identityKey !== 'string' || - identityKey.trim() === '' || - identityKey === 'unknown' - ) { + if (typeof identityKey !== 'string' || identityKey.trim() === '' || identityKey === 'unknown') { throw new WERR_UNAUTHORIZED('authenticated request identity is required') } return identityKey } -function firstRequestHeader (req: Request, name: string): string | undefined { +function firstRequestHeader(req: Request, name: string): string | undefined { const value = req.headers[name.toLowerCase()] return Array.isArray(value) ? value[0] : value } @@ -223,7 +213,7 @@ export class StorageServer { private readonly telemetry: Telemetry private readonly telemetryConfig?: TelemetryConfig - constructor (storage: StorageProvider, options: WalletStorageServerOptions) { + constructor(storage: StorageProvider, options: WalletStorageServerOptions) { this.storage = storage this.port = options.port this.wallet = options.wallet @@ -258,7 +248,7 @@ export class StorageServer { this.setupRoutes() } - private setupShortReqLogging (): void { + private setupShortReqLogging(): void { this.app.use((req: Request, res: Response, next: express.NextFunction) => { const contentLength = Number(req.headers['content-length'] || 0) @@ -273,7 +263,9 @@ export class StorageServer { ua: req.headers['user-agent'] || '-' } const traceContext = firstRequestHeader(req, 'X-Cloud-Trace-Context')?.split('/')[0] - if (traceContext) { logObj['logging.googleapis.com/trace'] = `projects/computing-with-integrity/traces/${traceContext}` } + if (traceContext) { + logObj['logging.googleapis.com/trace'] = `projects/computing-with-integrity/traces/${traceContext}` + } // Request bodies and BSV auth/payment headers can contain sensitive // material. Short-request logging records metadata only. @@ -284,22 +276,23 @@ export class StorageServer { }) } - private setupRoutes (): void { + private setupRoutes(): void { configureTrustProxy(this.app, this.trustProxy) this.app.disable('x-powered-by') - this.app.use(securityHeaders({ - environmentPrefix: 'WALLET_STORAGE', - ...this.securityHeadersPolicy - })) - this.app.use(corsPolicy({ - environmentPrefix: 'WALLET_STORAGE', - allowedOrigins: this.allowedOrigins, - methods: ['GET', 'PUT', 'POST', 'OPTIONS'] - })) - this.app.use(concurrencyLimit( - 'WALLET_STORAGE', - this.maxConcurrentRequests - )) + this.app.use( + securityHeaders({ + environmentPrefix: 'WALLET_STORAGE', + ...this.securityHeadersPolicy + }) + ) + this.app.use( + corsPolicy({ + environmentPrefix: 'WALLET_STORAGE', + allowedOrigins: this.allowedOrigins, + methods: ['GET', 'PUT', 'POST', 'OPTIONS'] + }) + ) + this.app.use(concurrencyLimit('WALLET_STORAGE', this.maxConcurrentRequests)) this.app.use( rateLimit( @@ -320,15 +313,19 @@ export class StorageServer { // decoded JSON value while keeping user-controlled strings inert even if a // consumer embeds a response in an HTML context. this.app.set('json escape', true) - this.app.use(express.json({ - limit: readBodyLimitBytes('WALLET_STORAGE_JSON', 30 * 1024 * 1024) - })) + this.app.use( + express.json({ + limit: readBodyLimitBytes('WALLET_STORAGE_JSON', 30 * 1024 * 1024) + }) + ) // Authentication must see the exact binary body bytes, so parse octet // streams before the auth middleware just as JSON is parsed above. - this.app.use(express.raw({ - type: 'application/octet-stream', - limit: readBodyLimitBytes('WALLET_STORAGE_BINARY', 8 * 1024 * 1024) - })) + this.app.use( + express.raw({ + type: 'application/octet-stream', + limit: readBodyLimitBytes('WALLET_STORAGE_BINARY', 8 * 1024 * 1024) + }) + ) this.app.use(bodyParserErrorHandler) this.app.get('/robots.txt', (req: Request, res: Response) => { @@ -347,13 +344,15 @@ export class StorageServer { } if (this.sessionManager != null) options.sessionManager = this.sessionManager this.app.use(createAuthMiddleware(options)) - const authenticatedRateLimit = rateLimit(rateLimitOptions( - { windowMs: 60_000, limit: 1_000 }, - { - keyGenerator: authenticatedIdentityKey, - ...this.rateLimitOptions - } - )) + const authenticatedRateLimit = rateLimit( + rateLimitOptions( + { windowMs: 60_000, limit: 1_000 }, + { + keyGenerator: authenticatedIdentityKey, + ...this.rateLimitOptions + } + ) + ) this.app.use(authenticatedRateLimit) if (this.monetize) { this.app.use( @@ -364,74 +363,59 @@ export class StorageServer { ) } - this.app.put( - '/action-batch/:batchId/pack', - async (req: Request, res: Response) => { - try { - const auth = await this.authenticatedAuth(req, true) - const batchId = String(req.params.batchId) - // Express types request bodies as `any`, and other body parsers can - // produce strings, arrays, or objects. Keep the runtime narrowing - // inline so both the transport and static data-flow analysis can see - // that only a real byte view reaches the binary pack decoder. - const rawBody: unknown = req.body - if ( - typeof rawBody !== 'object' || - rawBody === null || - !(rawBody instanceof Uint8Array) - ) { - throw new TypeError('binary action batch body required') - } - // Re-wrap without copying. Besides normalizing Buffer subclasses, - // this makes the decoder's runtime type independent of req.body. - const body = new Uint8Array(rawBody.buffer, rawBody.byteOffset, rawBody.byteLength) - const requestedEncoding = req.header(ACTION_BATCH_PACK_ENCODING_HEADER) ?? 'identity' - const encoding = supportedActionBatchPackEncodings() - .find(candidate => candidate === requestedEncoding) - if (encoding == null) throw new TypeError(`unsupported action batch pack encoding ${requestedEncoding}`) - const frame = await decompressActionBatchPack(body, encoding, ACTION_BATCH_MAX_PACK_BYTES) - const items = decodeActionBatchPack( - frame, - ACTION_BATCH_MAX_PACK_BYTES, - ACTION_BATCH_MAX_PACK_ITEMS - ) - await this.storage.putActionBatchPack(auth, { - batchId, - items, - maxPackBytes: ACTION_BATCH_MAX_PACK_BYTES, - maxItems: ACTION_BATCH_MAX_PACK_ITEMS - }) - res.status(200).json({ uploaded: true }) - } catch (error: unknown) { - const json = WalletError.unknownToJson(error) - res.status(400).json(JSON.parse(json)) + this.app.put('/action-batch/:batchId/pack', async (req: Request, res: Response) => { + try { + const auth = await this.authenticatedAuth(req, true) + const batchId = String(req.params.batchId) + // Express types request bodies as `any`, and other body parsers can + // produce strings, arrays, or objects. Keep the runtime narrowing + // inline so both the transport and static data-flow analysis can see + // that only a real byte view reaches the binary pack decoder. + const rawBody: unknown = req.body + if (typeof rawBody !== 'object' || rawBody === null || !(rawBody instanceof Uint8Array)) { + throw new TypeError('binary action batch body required') } + // Re-wrap without copying. Besides normalizing Buffer subclasses, + // this makes the decoder's runtime type independent of req.body. + const body = new Uint8Array(rawBody.buffer, rawBody.byteOffset, rawBody.byteLength) + const requestedEncoding = req.header(ACTION_BATCH_PACK_ENCODING_HEADER) ?? 'identity' + const encoding = supportedActionBatchPackEncodings().find(candidate => candidate === requestedEncoding) + if (encoding == null) throw new TypeError(`unsupported action batch pack encoding ${requestedEncoding}`) + const frame = await decompressActionBatchPack(body, encoding, ACTION_BATCH_MAX_PACK_BYTES) + const items = decodeActionBatchPack(frame, ACTION_BATCH_MAX_PACK_BYTES, ACTION_BATCH_MAX_PACK_ITEMS) + await this.storage.putActionBatchPack(auth, { + batchId, + items, + maxPackBytes: ACTION_BATCH_MAX_PACK_BYTES, + maxItems: ACTION_BATCH_MAX_PACK_ITEMS + }) + res.status(200).json({ uploaded: true }) + } catch (error: unknown) { + const json = WalletError.unknownToJson(error) + res.status(400).json(JSON.parse(json)) } - ) + }) - this.app.put( - '/action-batch/:batchId/blob/:digest', - async (req: Request, res: Response) => { - try { - const auth = await this.authenticatedAuth(req, true) - const batchId = String(req.params.batchId) - const digest = String(req.params.digest) - const body = req.body - if (!(body instanceof Uint8Array)) throw new TypeError('binary action batch body required') - await this.storage.putActionBatchBlob(auth, { batchId, digest, bytes: body }) - res.status(200).json({ uploaded: true }) - } catch (error: unknown) { - const json = WalletError.unknownToJson(error) - res.status(400).json(JSON.parse(json)) - } + this.app.put('/action-batch/:batchId/blob/:digest', async (req: Request, res: Response) => { + try { + const auth = await this.authenticatedAuth(req, true) + const batchId = String(req.params.batchId) + const digest = String(req.params.digest) + const body = req.body + if (!(body instanceof Uint8Array)) throw new TypeError('binary action batch body required') + await this.storage.putActionBatchBlob(auth, { batchId, digest, bytes: body }) + res.status(200).json({ uploaded: true }) + } catch (error: unknown) { + const json = WalletError.unknownToJson(error) + res.status(400).json(JSON.parse(json)) } - ) + }) // A single POST endpoint for JSON-RPC: this.app.post('/', this.handleRpcRequest.bind(this)) } - private async handleRpcRequest (req: Request, res: Response): Promise { + private async handleRpcRequest(req: Request, res: Response): Promise { if (!this.telemetry.enabled) return await this.handleRpcRequestCore(req, res) return await this.telemetry.withSpan( 'wallet.storage.rpc', @@ -448,11 +432,7 @@ export class StorageServer { ) } - private async handleRpcRequestCore ( - req: Request, - res: Response, - rpcSpan?: TelemetrySpan - ): Promise { + private async handleRpcRequestCore(req: Request, res: Response, rpcSpan?: TelemetrySpan): Promise { const useBinary = req.header(BINARY_ENCODING_HEADER) === BINARY_ENCODING const requestUsesBinary = req.header(BINARY_REQUEST_ENCODING_HEADER) === BINARY_ENCODING if (useBinary) res.set(BINARY_ENCODING_HEADER, BINARY_ENCODING) @@ -467,11 +447,16 @@ export class StorageServer { try { const dispatch = await this.dispatchRpcCall(method, params, req, logObj, rpcSpan) if (!dispatch.found) { - return this.sendRpc(res, useBinary, { - jsonrpc: '2.0', - error: { code: -32601, message: `Method not found: ${method}` }, - id - }, 400) + return this.sendRpc( + res, + useBinary, + { + jsonrpc: '2.0', + error: { code: -32601, message: `Method not found: ${method}` }, + id + }, + 400 + ) } return this.sendRpc(res, useBinary, { jsonrpc: '2.0', result: dispatch.result, id }) } catch (error: unknown) { @@ -479,7 +464,7 @@ export class StorageServer { } } - private traceHttpRequest (req: Request, res: Response, next: express.NextFunction): void { + private traceHttpRequest(req: Request, res: Response, next: express.NextFunction): void { const span = this.telemetry.startSpan('wallet.storage.http.request', { component: 'wallet-storage-server', kind: 'server', @@ -512,14 +497,14 @@ export class StorageServer { next() } - private sendRpc (res: Response, useBinary: boolean, payload: unknown, status: number = 200): Response { + private sendRpc(res: Response, useBinary: boolean, payload: unknown, status: number = 200): Response { res.set('X-Content-Type-Options', 'nosniff') // Normalize with the negotiated binary replacer, then let Express emit // the JSON response through its escaping-aware JSON sink. return res.status(status).json(JSON.parse(stringifyJsonRpc(payload, useBinary))) } - private sendRpcError (res: Response, useBinary: boolean, id: unknown, error: unknown): Response { + private sendRpcError(res: Response, useBinary: boolean, id: unknown, error: unknown): Response { /** * Convert errors to standard JSON object format that can be converted back * to WalletError derived objects on the client side and re-thrown. @@ -532,12 +517,7 @@ export class StorageServer { }) } - private createRpcLog ( - req: Request, - method: string, - id: unknown, - params: any[] - ): Record | undefined { + private createRpcLog(req: Request, method: string, id: unknown, params: any[]): Record | undefined { if (!this.logRpcRequests) return undefined const logObj: Record = { @@ -558,7 +538,7 @@ export class StorageServer { return logObj } - private async dispatchRpcCall ( + private async dispatchRpcCall( method: string, params: any[], req: Request, @@ -567,9 +547,7 @@ export class StorageServer { ): Promise { if (!storageRpcMethods.has(method)) return { found: false } - const storageMethod = method === 'findOutputBaskets' - ? 'findOutputBasketsAuth' - : method + const storageMethod = method === 'findOutputBaskets' ? 'findOutputBasketsAuth' : method const storageHandler = (this.storage as any)[storageMethod] if (typeof storageHandler !== 'function') return { found: false } @@ -598,7 +576,7 @@ export class StorageServer { } } - private async traceRpcStep ( + private async traceRpcStep( name: string, parent: TelemetrySpan | undefined, callback: () => Promise | T, @@ -617,7 +595,7 @@ export class StorageServer { ) } - private async authorizeRpcCall ( + private async authorizeRpcCall( method: string, params: any[], req: Request, @@ -647,14 +625,14 @@ export class StorageServer { } } - private logIgnoredDestroy (logObj?: Record): void { + private logIgnoredDestroy(logObj?: Record): void { if (logObj == null) return logObj.result = undefined logObj.comment = 'IGNORED' console.log(JSON.stringify(logObj)) } - private authorizeAdminStats (params: any[], req: Request): void { + private authorizeAdminStats(params: any[], req: Request): void { const identityKey = requiredAuthenticatedIdentityKey(req) if (params[0] !== identityKey) { throw new WERR_UNAUTHORIZED('function may only access authenticated admin user.') @@ -664,7 +642,7 @@ export class StorageServer { } } - private async authorizeStandardRpcCall (method: string, params: any[], req: Request): Promise { + private async authorizeStandardRpcCall(method: string, params: any[], req: Request): Promise { if (authIdRpcMethods.has(method)) { await this.bindAuthenticatedAuth(params, req, actionBatchRpcMethods.has(method)) return @@ -672,7 +650,7 @@ export class StorageServer { await this.validateParam0(params, req) } - private createRpcLogger (method: string, params: any[]): WalletLoggerInterface | undefined { + private createRpcLogger(method: string, params: any[]): WalletLoggerInterface | undefined { if (this.makeLogger == null || params[1] == null || typeof params[1] !== 'object') return undefined const logger = this.makeLogger(params[1].logger) @@ -685,7 +663,7 @@ export class StorageServer { return logger } - private finishRpcLogging (logger: WalletLoggerInterface | undefined, result: any): void { + private finishRpcLogging(logger: WalletLoggerInterface | undefined, result: any): void { if (logger == null) return logger.groupEnd() @@ -696,12 +674,12 @@ export class StorageServer { } } - private async authenticatedAuth (req: Request, requireActive: boolean): Promise { + private async authenticatedAuth(req: Request, requireActive: boolean): Promise { const identityKey = requiredAuthenticatedIdentityKey(req) const { user } = await this.storage.findOrInsertUser(identityKey) const isActive = user.activeStorage === this.storage.getSettings().storageIdentityKey if (requireActive && !isActive) { - throw new WERR_NOT_ACTIVE('action batch methods require the authenticated user\'s active storage provider') + throw new WERR_NOT_ACTIVE("action batch methods require the authenticated user's active storage provider") } return { identityKey, @@ -710,7 +688,7 @@ export class StorageServer { } } - private async bindAuthenticatedAuth (params: any[], req: Request, requireActive: boolean): Promise { + private async bindAuthenticatedAuth(params: any[], req: Request, requireActive: boolean): Promise { if (!Array.isArray(params)) throw new WERR_UNAUTHORIZED('authenticated RPC parameters are required') const claimed = typeof params[0] === 'object' && params[0] != null ? params[0] : {} const identityKey = requiredAuthenticatedIdentityKey(req) @@ -725,13 +703,15 @@ export class StorageServer { } } - private async validateParam0 (params: any[], req: Request): Promise { + private async validateParam0(params: any[], req: Request): Promise { if (!Array.isArray(params)) throw new WERR_UNAUTHORIZED('authenticated RPC parameters are required') if (typeof params[0] !== 'object' || !params[0]) { params[0] = {} } const identityKey = requiredAuthenticatedIdentityKey(req) - if (params[0].identityKey && params[0].identityKey !== identityKey) { throw new WERR_UNAUTHORIZED('identityKey does not match authentication') } + if (params[0].identityKey && params[0].identityKey !== identityKey) { + throw new WERR_UNAUTHORIZED('identityKey does not match authentication') + } // console.log('looking up user with identityKey:', identityKey) const { user } = await this.storage.findOrInsertUser(identityKey) params[0].reqAuthUserId = user.userId @@ -740,14 +720,14 @@ export class StorageServer { server: any - public start (): void { + public start(): void { this.server = this.app.listen(this.port, () => { console.log(`WalletStorageServer listening at http://localhost:${this.port}`) }) configureHttpServer(this.server, 'WALLET_STORAGE', this.httpPolicy) } - public async close (): Promise { + public async close(): Promise { if (this.server) { await this.server.close(() => { // console.log('WalletStorageServer closed') @@ -756,7 +736,9 @@ export class StorageServer { } /** @see {@link validateDate} */ - validateDate (date: Date | string | number): Date { return validateDate(date) } + validateDate(date: Date | string | number): Date { + return validateDate(date) + } /** * Helper to force uniform behavior across database engines. diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts index 8c57571c5..6b404f28e 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/__test/StorageClient.telemetry.test.ts @@ -35,7 +35,9 @@ describe('StorageClient telemetry', () => { const result = await Reflect.get(client, 'rpcCall').call(client, 'isAvailable', [{ userId: 1 }]) expect(result).toEqual({ available: true }) - expect((requestInit?.headers as Record).traceparent).toMatch( + const requestHeaders = requestInit?.headers + expect(requestHeaders).toBeDefined() + expect((requestHeaders as Record).traceparent).toMatch( /^00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-[0-9a-f]{16}-01$/ ) const byName = new Map(events.map(event => [event.name, event])) From 556fc12a031f13a94b90b0fd05669de4ae254502 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 29 Jul 2026 20:07:06 -0700 Subject: [PATCH 8/9] fix: include hash subpath declarations in platform checks --- .../wallet/wallet-toolbox/client/tsconfig.typecheck.json | 8 +++++++- .../wallet/wallet-toolbox/mobile/tsconfig.typecheck.json | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/wallet/wallet-toolbox/client/tsconfig.typecheck.json b/packages/wallet/wallet-toolbox/client/tsconfig.typecheck.json index 03586e931..64ac0c675 100644 --- a/packages/wallet/wallet-toolbox/client/tsconfig.typecheck.json +++ b/packages/wallet/wallet-toolbox/client/tsconfig.typecheck.json @@ -9,6 +9,12 @@ "types": ["node", "vitest/globals"] }, "files": [], - "include": ["../src/index.client.ts", "test/**/*.ts", "tsdown.config.ts", "vitest.config.ts"], + "include": [ + "../src/index.client.ts", + "../src/types/hash-wasm-subpaths.d.ts", + "test/**/*.ts", + "tsdown.config.ts", + "vitest.config.ts" + ], "exclude": ["node_modules", "out", "../out", "../mobile/out"] } diff --git a/packages/wallet/wallet-toolbox/mobile/tsconfig.typecheck.json b/packages/wallet/wallet-toolbox/mobile/tsconfig.typecheck.json index 6deef1e69..9704e8afb 100644 --- a/packages/wallet/wallet-toolbox/mobile/tsconfig.typecheck.json +++ b/packages/wallet/wallet-toolbox/mobile/tsconfig.typecheck.json @@ -9,6 +9,12 @@ "types": ["node", "vitest/globals"] }, "files": [], - "include": ["../src/index.mobile.ts", "test/**/*.ts", "tsdown.config.ts", "vitest.config.ts"], + "include": [ + "../src/index.mobile.ts", + "../src/types/hash-wasm-subpaths.d.ts", + "test/**/*.ts", + "tsdown.config.ts", + "vitest.config.ts" + ], "exclude": ["node_modules", "out", "../out", "../client/out"] } From 319cb489d4869301f3611059f06bc843204cee32 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Wed, 29 Jul 2026 20:21:01 -0700 Subject: [PATCH 9/9] refactor: simplify storage client request setup --- .../src/storage/remoting/StorageClient.ts | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts index d4177f5b5..aa9ab0954 100644 --- a/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts +++ b/packages/wallet/wallet-toolbox/src/storage/remoting/StorageClient.ts @@ -11,6 +11,11 @@ import { stringifyJsonRpc } from './BinaryJson' +interface RpcLoggerState { + logger?: WalletLoggerInterface + requestOptions?: Record +} + /** * `StorageClient` implements the `WalletStorageProvider` interface which allows it to * serve as a BRC-100 wallet's active storage. @@ -41,19 +46,12 @@ export class StorageClient extends StorageClientBase { */ protected async rpcCall(method: string, params: unknown[]): Promise { return await this.traceRpcCall(method, params, async rpcSpan => { - const requestOptions = - typeof params[1] === 'object' && params[1] !== null ? (params[1] as Record) : undefined - const logger = requestOptions?.logger as WalletLoggerInterface | undefined + const loggerState = this.startRpcLogging(method, params) + const { logger } = loggerState try { const id = this.nextId++ - if (logger != null) { - // Replace logger object with seed json object to continue logging on request server. - logger.group(`StorageClient ${method}`) - requestOptions!.logger = { indent: logger.indent || 0 } - } - const body = { jsonrpc: '2.0', method, @@ -144,11 +142,27 @@ export class StorageClient extends StorageClientBase { logWalletError(error_, logger, 'error setting up request to remote service') throw error_ } finally { - if (logger != null) { - // Restore original logger in params - requestOptions!.logger = logger - } + this.restoreRpcLogging(loggerState) } }) } + + private startRpcLogging(method: string, params: unknown[]): RpcLoggerState { + const requestOptions = + typeof params[1] === 'object' && params[1] !== null ? (params[1] as Record) : undefined + const logger = requestOptions?.logger as WalletLoggerInterface | undefined + if (logger != null && requestOptions != null) { + // Replace logger object with seed json object to continue logging on request server. + logger.group(`StorageClient ${method}`) + requestOptions.logger = { indent: logger.indent || 0 } + } + return { logger, requestOptions } + } + + private restoreRpcLogging({ logger, requestOptions }: RpcLoggerState): void { + if (logger != null && requestOptions != null) { + // Restore original logger in params + requestOptions.logger = logger + } + } }