From be026b847ad4b158567c94d6a8229476cadbdfc9 Mon Sep 17 00:00:00 2001 From: iam-mercy Date: Fri, 25 Sep 2026 06:55:42 +0000 Subject: [PATCH] fix: resolve issues #840, #781, #780, #779 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #840 — XBullAdapter: drop previous onAccountChange registration before installing a new one in setupAccountChangeListener(). Mirrors LobstrAdapter. Repeated connect() calls now leave at most one live listener; disconnect() leaves zero; a post-disconnect account-change event cannot write back into an adapter the app has torn down. #781 — Add computeMovingAverage(samples, windowSize) to src/fees/trend.ts. Returns a Simple Moving Average series of the same length as the input, padding the first windowSize-1 entries with NaN. Throws RangeError when windowSize < 1. Pure function with no side effects. #780 — AnchorVerifier gains an optional pinnedCertFingerprints option (Record). When a fingerprint is configured for a domain, the TLS certificate is verified before the TOML is fetched or trusted. A mismatch throws CertificatePinningError naming the domain. _fetchCertFingerprint is injectable for unit-test isolation. #779 — StellarTomlParser exports SUPPORTED_TOML_VERSIONS ([2.0, 2.1]) and validates the VERSION field immediately after successful TOML parsing. An unsupported version throws UnsupportedTomlVersionError naming the encountered version. Parsing a TOML without a VERSION field is accepted unchanged. VERSION check runs before caching so a rejected parse never pollutes the cache. CertificatePinningError and UnsupportedTomlVersionError are added to src/errors.ts following the existing StellarSplitError pattern. All new symbols are exported from src/index.ts. 129 tests pass. --- src/anchors/AnchorVerifier.ts | 161 +++++++++++++++- src/anchors/StellarTomlParser.ts | 25 +++ src/errors.ts | 47 +++++ src/fees/trend.ts | 46 +++++ src/index.ts | 28 +++ src/wallets/adapters/XBullAdapter.ts | 9 + test/anchorPinningAndVersion.test.ts | 275 +++++++++++++++++++++++++++ test/feeTrend.test.ts | 77 ++++++++ test/xbullAdapter.test.ts | 139 ++++++++++++++ 9 files changed, 803 insertions(+), 4 deletions(-) create mode 100644 test/anchorPinningAndVersion.test.ts create mode 100644 test/feeTrend.test.ts create mode 100644 test/xbullAdapter.test.ts diff --git a/src/anchors/AnchorVerifier.ts b/src/anchors/AnchorVerifier.ts index f94faea..4c86ae2 100644 --- a/src/anchors/AnchorVerifier.ts +++ b/src/anchors/AnchorVerifier.ts @@ -5,8 +5,10 @@ * confirm bidirectional verification: * * 1. Load the issuer account from Horizon to obtain its `home_domain`. - * 2. Fetch the TOML from that `home_domain`. - * 3. Assert that the TOML's CURRENCIES array contains an entry matching + * 2. Optionally verify the TLS certificate fingerprint for that domain + * against a caller-supplied pin (#780). + * 3. Fetch the TOML from that `home_domain`. + * 4. Assert that the TOML's CURRENCIES array contains an entry matching * both `assetCode` and the issuer address. * * Returns a `VerificationResult` describing the outcome so callers can @@ -14,9 +16,84 @@ */ import { Horizon } from "@stellar/stellar-sdk"; +import { CertificatePinningError } from "../errors.js"; import { StellarTomlParser } from "./StellarTomlParser.js"; import type { TomlCurrency, StellarTomlParserOptions } from "./StellarTomlParser.js"; +// --------------------------------------------------------------------------- +// Certificate fingerprint fetcher +// --------------------------------------------------------------------------- + +/** + * Retrieves the SHA-256 fingerprint of the TLS certificate served by `domain` + * on port 443. Returns a colon-separated uppercase hex string in the + * standard `openssl` format (e.g. `"AA:BB:CC:..."`). + * + * Uses Node.js `node:https` and `node:crypto` — only available in Node.js + * environments. Browser environments should not configure + * `pinnedCertFingerprints` since TLS certificate inspection is unavailable + * in that context. + * + * @internal Exported for testing purposes; prefer using the + * `_fetchCertFingerprint` constructor option to inject a test double. + */ +export async function defaultFetchCertFingerprint( + domain: string, + timeoutMs = 10_000, +): Promise { + // Dynamic imports keep the browser bundle clean. + const [httpsModule, cryptoModule] = await Promise.all([ + import("node:https"), + import("node:crypto"), + ]); + const https = httpsModule; + const { createHash } = cryptoModule; + + return new Promise((resolve, reject) => { + const req = https.request( + { + host: domain, + port: 443, + method: "HEAD", + path: "/", + // Allow self-signed / expired certs so we can read the raw DER bytes; + // the fingerprint comparison is the trust decision. + rejectUnauthorized: false, + }, + (res: any) => { + const socket = res.socket as import("tls").TLSSocket; + const cert = socket.getPeerCertificate(false); + + if (!cert || !cert.raw) { + reject(new Error(`No certificate received from domain "${domain}"`)); + req.destroy(); + return; + } + + // SHA-256 in AA:BB:CC... format + const hash = createHash("sha256") + .update(cert.raw) + .digest("hex") + .toUpperCase() + .match(/.{1,2}/g)! + .join(":"); + + resolve(hash); + req.destroy(); + }, + ); + + req.setTimeout(timeoutMs, () => { + req.destroy( + new Error(`Certificate fetch timed out for domain "${domain}"`), + ); + }); + + req.on("error", reject); + req.end(); + }); +} + // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- @@ -48,6 +125,38 @@ export interface AnchorVerifierOptions extends StellarTomlParserOptions { * @default "https://horizon.stellar.org" */ horizonUrl?: string; + + /** + * Optional map of domain → expected SHA-256 TLS certificate fingerprint. + * + * When a domain appears in this map, `AnchorVerifier` will retrieve the + * server's TLS certificate before trusting any response from that domain + * and compare its SHA-256 fingerprint against the configured value. + * + * A mismatch throws a {@link CertificatePinningError} naming the domain, + * protecting against compromised DNS or rogue Certificate Authorities. + * + * Fingerprint format: colon-separated uppercase hex pairs, as produced by + * `openssl x509 -fingerprint -sha256`, e.g.: + * ``` + * "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99" + * ``` + * + * Certificate pinning uses Node.js `node:https`; browser environments + * skip the fingerprint check automatically when `_fetchCertFingerprint` + * is not injected and `node:https` is unavailable. + */ + pinnedCertFingerprints?: Record; + + /** + * Override the function used to fetch TLS certificate fingerprints. + * + * Intended for testing — inject a mock that returns a controlled + * fingerprint without making a real TLS connection. + * + * @internal + */ + _fetchCertFingerprint?: (domain: string, timeoutMs?: number) => Promise; } // --------------------------------------------------------------------------- @@ -58,10 +167,16 @@ export interface AnchorVerifierOptions extends StellarTomlParserOptions { * Verifies that an asset issuer's `home_domain` TOML correctly lists the * asset, confirming the anchor's on-chain ↔ off-chain consistency. * + * When `pinnedCertFingerprints` is provided, the TLS certificate of each + * pinned domain is checked before its TOML is fetched or trusted (#780). + * * @example * ```ts * const verifier = new AnchorVerifier({ * horizonUrl: "https://horizon.stellar.org", + * pinnedCertFingerprints: { + * "circle.io": "AA:BB:CC:...", + * }, * }); * * const result = await verifier.verify("GA5ZSEJ...", "USDC"); @@ -73,15 +188,25 @@ export interface AnchorVerifierOptions extends StellarTomlParserOptions { export class AnchorVerifier { private readonly _server: Horizon.Server; private readonly _parser: StellarTomlParser; + private readonly _pinnedCertFingerprints: Record; + private readonly _fetchTimeoutMs: number; + private readonly _fetchCertFingerprintFn: ( + domain: string, + timeoutMs?: number, + ) => Promise; constructor(options: AnchorVerifierOptions = {}) { this._server = new Horizon.Server( options.horizonUrl ?? "https://horizon.stellar.org", ); + this._fetchTimeoutMs = options.fetchTimeoutMs ?? 10_000; this._parser = new StellarTomlParser({ tomlCacheTtlMs: options.tomlCacheTtlMs, fetchTimeoutMs: options.fetchTimeoutMs, }); + this._pinnedCertFingerprints = options.pinnedCertFingerprints ?? {}; + this._fetchCertFingerprintFn = + options._fetchCertFingerprint ?? defaultFetchCertFingerprint; } /** @@ -89,6 +214,10 @@ export class AnchorVerifier { * * @param assetIssuer - Stellar G… address of the asset issuer account. * @param assetCode - Asset code to look up in the CURRENCIES array. + * + * @throws {CertificatePinningError} when a pinned domain serves a + * certificate whose SHA-256 fingerprint does not match the configured + * value. */ async verify( assetIssuer: string, @@ -121,7 +250,31 @@ export class AnchorVerifier { } // ------------------------------------------------------------------- - // Step 2: Fetch TOML from home_domain + // Step 2 (optional): Certificate pinning check (#780) + // + // When the caller has configured a fingerprint for this domain, verify + // the server's TLS certificate before fetching or trusting any content. + // ------------------------------------------------------------------- + const expectedFingerprint = this._pinnedCertFingerprints[homeDomain]; + if (expectedFingerprint) { + const actualFingerprint = await this._fetchCertFingerprintFn( + homeDomain, + this._fetchTimeoutMs, + ); + + if ( + actualFingerprint.toUpperCase() !== expectedFingerprint.toUpperCase() + ) { + throw new CertificatePinningError( + homeDomain, + expectedFingerprint, + actualFingerprint, + ); + } + } + + // ------------------------------------------------------------------- + // Step 3: Fetch TOML from home_domain // ------------------------------------------------------------------- const tomlUrl = `https://${homeDomain}/.well-known/stellar.toml`; let metadata: Awaited>; @@ -136,7 +289,7 @@ export class AnchorVerifier { } // ------------------------------------------------------------------- - // Step 3: Find a matching CURRENCIES entry + // Step 4: Find a matching CURRENCIES entry // ------------------------------------------------------------------- const currencies = metadata.CURRENCIES ?? []; const match = currencies.find( diff --git a/src/anchors/StellarTomlParser.ts b/src/anchors/StellarTomlParser.ts index e135af2..3908776 100644 --- a/src/anchors/StellarTomlParser.ts +++ b/src/anchors/StellarTomlParser.ts @@ -10,6 +10,21 @@ // `toml` is a CommonJS package — we import it as a namespace. import * as toml from "toml"; +import { UnsupportedTomlVersionError } from "../errors.js"; + +// --------------------------------------------------------------------------- +// Supported TOML schema versions (#779) +// --------------------------------------------------------------------------- + +/** + * Stellar TOML schema versions that this parser accepts. + * + * Versions outside this list cause {@link StellarTomlParser.fetch} to throw + * an `UnsupportedTomlVersionError` before the parsed metadata is returned. + * This prevents silently producing incorrect data when a breaking schema + * revision is deployed by an anchor. + */ +export const SUPPORTED_TOML_VERSIONS: readonly number[] = [2.0, 2.1]; // --------------------------------------------------------------------------- // SEP-1 typed structures @@ -235,6 +250,16 @@ export class StellarTomlParser { ); } + // VERSION check (#779): this is the first validation after successful + // parsing. When the TOML carries a VERSION field we verify it is one we + // support so we never silently process a breaking schema revision. + if (parsed["VERSION"] !== undefined) { + const version = Number(parsed["VERSION"]); + if (!SUPPORTED_TOML_VERSIONS.includes(version)) { + throw new UnsupportedTomlVersionError(String(parsed["VERSION"])); + } + } + return { domain, tomlUrl, diff --git a/src/errors.ts b/src/errors.ts index f35bd2a..dd68c6a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -2109,6 +2109,53 @@ export class StellarTomlFetchError extends StellarSplitError { } } +/** + * Thrown when a TLS certificate fingerprint for an anchor HTTPS endpoint does + * not match the configured pinned fingerprint (#780). + * + * The fingerprint should be a colon-separated uppercase hex string in the + * standard `openssl` format, e.g. `"AA:BB:CC:..."`. + */ +export class CertificatePinningError extends StellarSplitError { + readonly domain: string; + readonly expectedFingerprint: string; + readonly actualFingerprint: string; + + constructor(domain: string, expectedFingerprint: string, actualFingerprint: string) { + super( + `Certificate fingerprint mismatch for domain "${domain}": ` + + `expected "${expectedFingerprint}", got "${actualFingerprint}"`, + "CERTIFICATE_PINNING_ERROR", + { domain, expectedFingerprint, actualFingerprint }, + ); + this.name = "CertificatePinningError"; + this.domain = domain; + this.expectedFingerprint = expectedFingerprint; + this.actualFingerprint = actualFingerprint; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * Thrown when a `stellar.toml` file carries a VERSION that is not listed in + * {@link SUPPORTED_TOML_VERSIONS} (#779). + */ +export class UnsupportedTomlVersionError extends StellarSplitError { + readonly encounteredVersion: string; + + constructor(encounteredVersion: string) { + super( + `Unsupported stellar.toml VERSION "${encounteredVersion}". ` + + `Supported versions: ${JSON.stringify([2.0, 2.1])}`, + "UNSUPPORTED_TOML_VERSION", + { encounteredVersion }, + ); + this.name = "UnsupportedTomlVersionError"; + this.encounteredVersion = encounteredVersion; + Object.setPrototypeOf(this, new.target.prototype); + } +} + /** Thrown when all channel accounts in the pool are busy and the acquire timeout elapses. */ export class ChannelExhaustedError extends StellarSplitError { readonly poolSize: number; diff --git a/src/fees/trend.ts b/src/fees/trend.ts index 5013149..0bc5c00 100644 --- a/src/fees/trend.ts +++ b/src/fees/trend.ts @@ -103,3 +103,49 @@ export class FeeTrendAnalyzer { this.buffer.evictOldestWhile((sample) => sample.capturedAt < cutoff); } } + +// --------------------------------------------------------------------------- +// computeMovingAverage (#781) +// --------------------------------------------------------------------------- + +/** + * Computes a Simple Moving Average (SMA) series over `samples`. + * + * The returned array has the same length as `samples`. The first + * `windowSize - 1` entries are padded with `NaN` because there are not yet + * enough data points to fill a complete window. + * + * The function is pure — it neither reads nor mutates any external state. + * + * @param samples - Input data series (e.g. fee samples in stroops). + * @param windowSize - Number of consecutive samples per average window. + * Must be an integer ≥ 1; throws `RangeError` otherwise. + * @returns - SMA series of the same length as `samples`. + * + * @example + * ```ts + * computeMovingAverage([100, 200, 300, 400, 500], 3); + * // => [NaN, NaN, 200, 300, 400] + * ``` + * + * @throws {RangeError} when `windowSize` is less than 1. + */ +export function computeMovingAverage( + samples: number[], + windowSize: number, +): number[] { + if (!Number.isInteger(windowSize) || windowSize < 1) { + throw new RangeError( + `windowSize must be an integer ≥ 1, got ${windowSize}`, + ); + } + + return samples.map((_, i) => { + if (i < windowSize - 1) return NaN; + let sum = 0; + for (let j = i - windowSize + 1; j <= i; j++) { + sum += samples[j]!; + } + return sum / windowSize; + }); +} diff --git a/src/index.ts b/src/index.ts index f9cb56b..e204811 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1427,3 +1427,31 @@ export type { SubmitTransactionOptions, SubmitServer, } from "./transaction/submit.js"; + +// --------------------------------------------------------------------------- +// #840 — XBullAdapter listener-leak fix +// --------------------------------------------------------------------------- + +export { XBullAdapter } from "./wallets/adapters/XBullAdapter.js"; + +// --------------------------------------------------------------------------- +// #781 — Moving average for fee trend analysis +// --------------------------------------------------------------------------- + +export { computeMovingAverage } from "./fees/trend.js"; + +// --------------------------------------------------------------------------- +// #780 — Certificate pinning for anchor HTTPS endpoints +// --------------------------------------------------------------------------- + +export { AnchorVerifier } from "./anchors/AnchorVerifier.js"; +export type { AnchorVerifierOptions, VerificationResult } from "./anchors/AnchorVerifier.js"; +export { CertificatePinningError } from "./errors.js"; + +// --------------------------------------------------------------------------- +// #779 — TOML schema version validation +// --------------------------------------------------------------------------- + +export { StellarTomlParser, SUPPORTED_TOML_VERSIONS } from "./anchors/StellarTomlParser.js"; +export type { StellarTomlParserOptions, TomlMetadata, TomlCurrency } from "./anchors/StellarTomlParser.js"; +export { UnsupportedTomlVersionError } from "./errors.js"; diff --git a/src/wallets/adapters/XBullAdapter.ts b/src/wallets/adapters/XBullAdapter.ts index 95b0eca..995c79e 100644 --- a/src/wallets/adapters/XBullAdapter.ts +++ b/src/wallets/adapters/XBullAdapter.ts @@ -90,6 +90,15 @@ export class XBullAdapter implements WalletAdapter { private setupAccountChangeListener(): void { if (!window.xbull) return; + // Drop the previous registration before installing a new one so that + // repeated connect() calls (e.g. reconnect after a dropped session or a + // component remount) never leave more than one live listener registered + // with the wallet. Mirrors the approach used by LobstrAdapter. + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + } + this.unsubscribe = window.xbull.onAccountChange((publicKey: string) => { this.currentPublicKey = publicKey; diff --git a/test/anchorPinningAndVersion.test.ts b/test/anchorPinningAndVersion.test.ts new file mode 100644 index 0000000..bb987bb --- /dev/null +++ b/test/anchorPinningAndVersion.test.ts @@ -0,0 +1,275 @@ +/** + * Tests for: + * - Certificate pinning in AnchorVerifier (#780) + * - TOML schema version validation in StellarTomlParser (#779) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Horizon } from "@stellar/stellar-sdk"; +import { StellarTomlParser, SUPPORTED_TOML_VERSIONS } from "../src/anchors/StellarTomlParser.js"; +import { AnchorVerifier } from "../src/anchors/AnchorVerifier.js"; +import { CertificatePinningError, UnsupportedTomlVersionError } from "../src/errors.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const ISSUER = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; +const DOMAIN = "example.com"; +const FINGERPRINT_OK = "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"; +const FINGERPRINT_BAD = "11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00"; + +const MINIMAL_TOML = ` +[[CURRENCIES]] +code="USDC" +issuer="${ISSUER}" +`; + +/** Build a TOML string with an optional header line (e.g. VERSION). */ +function makeToml(header = "") { + return `${header ? header + "\n" : ""} +[[CURRENCIES]] +code="USDC" +issuer="${ISSUER}" +`; +} + +function makeAccountWithDomain(domain: string | undefined) { + return { sequenceNumber: () => "100", home_domain: domain, balances: [] }; +} + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +let fetchSpy: ReturnType; +let loadAccountSpy: ReturnType; + +beforeEach(() => { + fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + text: () => Promise.resolve(MINIMAL_TOML), + }); + vi.stubGlobal("fetch", fetchSpy); + + loadAccountSpy = vi.spyOn(Horizon.Server.prototype, "loadAccount") as any; + loadAccountSpy.mockResolvedValue(makeAccountWithDomain(DOMAIN) as any); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +// --------------------------------------------------------------------------- +// #779 — SUPPORTED_TOML_VERSIONS export +// --------------------------------------------------------------------------- + +describe("StellarTomlParser — SUPPORTED_TOML_VERSIONS export (#779)", () => { + it("exports SUPPORTED_TOML_VERSIONS as a non-empty readonly array", () => { + expect(Array.isArray(SUPPORTED_TOML_VERSIONS)).toBe(true); + expect(SUPPORTED_TOML_VERSIONS.length).toBeGreaterThan(0); + }); + + it("includes version 2.0", () => { + expect(SUPPORTED_TOML_VERSIONS).toContain(2.0); + }); + + it("includes version 2.1", () => { + expect(SUPPORTED_TOML_VERSIONS).toContain(2.1); + }); +}); + +// --------------------------------------------------------------------------- +// #779 — VERSION field validation +// --------------------------------------------------------------------------- + +describe("StellarTomlParser — VERSION field validation (#779)", () => { + it("accepts a TOML with a supported VERSION (2.0)", async () => { + fetchSpy.mockResolvedValue({ + ok: true, status: 200, statusText: "OK", + text: () => Promise.resolve(makeToml("VERSION=2.0")), + }); + const parser = new StellarTomlParser(); + await expect(parser.fetch(DOMAIN)).resolves.toBeDefined(); + }); + + it("accepts a TOML with a supported VERSION (2.1)", async () => { + fetchSpy.mockResolvedValue({ + ok: true, status: 200, statusText: "OK", + text: () => Promise.resolve(makeToml("VERSION=2.1")), + }); + const parser = new StellarTomlParser(); + await expect(parser.fetch(DOMAIN)).resolves.toBeDefined(); + }); + + it("accepts a TOML with no VERSION field (check skipped)", async () => { + const parser = new StellarTomlParser(); + await expect(parser.fetch(DOMAIN)).resolves.toBeDefined(); + }); + + it("throws UnsupportedTomlVersionError for VERSION=3.0", async () => { + fetchSpy.mockResolvedValue({ + ok: true, status: 200, statusText: "OK", + text: () => Promise.resolve(makeToml("VERSION=3.0")), + }); + const parser = new StellarTomlParser(); + await expect(parser.fetch(DOMAIN)).rejects.toBeInstanceOf(UnsupportedTomlVersionError); + }); + + it("UnsupportedTomlVersionError carries the encountered version string", async () => { + fetchSpy.mockResolvedValue({ + ok: true, status: 200, statusText: "OK", + text: () => Promise.resolve(makeToml("VERSION=99.9")), + }); + const parser = new StellarTomlParser(); + await expect(parser.fetch(DOMAIN)).rejects.toMatchObject({ + encounteredVersion: "99.9", + }); + }); + + it("does not cache result when VERSION check throws (isCached stays false)", async () => { + fetchSpy.mockResolvedValue({ + ok: true, status: 200, statusText: "OK", + text: () => Promise.resolve(makeToml("VERSION=5.0")), + }); + const parser = new StellarTomlParser(); + await expect(parser.fetch(DOMAIN)).rejects.toBeInstanceOf(UnsupportedTomlVersionError); + expect(parser.isCached(DOMAIN)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// #780 — Certificate pinning +// --------------------------------------------------------------------------- + +describe("AnchorVerifier — pinnedCertFingerprints option (#780)", () => { + it("verify() succeeds normally when no pinnedCertFingerprints are configured", async () => { + const verifier = new AnchorVerifier(); + const result = await verifier.verify(ISSUER, "USDC"); + expect(result.verified).toBe(true); + }); + + it("AnchorVerifierOptions.pinnedCertFingerprints is accepted without TypeScript errors", () => { + const verifier = new AnchorVerifier({ + pinnedCertFingerprints: { "example.com": FINGERPRINT_OK }, + }); + expect(verifier).toBeInstanceOf(AnchorVerifier); + }); + + it("verify() succeeds when actual fingerprint matches the pin", async () => { + const verifier = new AnchorVerifier({ + pinnedCertFingerprints: { [DOMAIN]: FINGERPRINT_OK }, + // Inject a mock that returns the expected fingerprint + _fetchCertFingerprint: vi.fn().mockResolvedValue(FINGERPRINT_OK), + }); + + const result = await verifier.verify(ISSUER, "USDC"); + expect(result.verified).toBe(true); + }); + + it("throws CertificatePinningError when actual fingerprint mismatches the pin", async () => { + const verifier = new AnchorVerifier({ + pinnedCertFingerprints: { [DOMAIN]: FINGERPRINT_OK }, + // Mock returns a different fingerprint → mismatch + _fetchCertFingerprint: vi.fn().mockResolvedValue(FINGERPRINT_BAD), + }); + + await expect(verifier.verify(ISSUER, "USDC")).rejects.toBeInstanceOf( + CertificatePinningError, + ); + }); + + it("CertificatePinningError names the domain", async () => { + const verifier = new AnchorVerifier({ + pinnedCertFingerprints: { [DOMAIN]: FINGERPRINT_OK }, + _fetchCertFingerprint: vi.fn().mockResolvedValue(FINGERPRINT_BAD), + }); + + try { + await verifier.verify(ISSUER, "USDC"); + expect.fail("Expected CertificatePinningError"); + } catch (err) { + expect(err).toBeInstanceOf(CertificatePinningError); + expect((err as CertificatePinningError).domain).toBe(DOMAIN); + } + }); + + it("_fetchCertFingerprint is called with the home_domain and timeout", async () => { + const mockFetchFp = vi.fn().mockResolvedValue(FINGERPRINT_OK); + const verifier = new AnchorVerifier({ + pinnedCertFingerprints: { [DOMAIN]: FINGERPRINT_OK }, + fetchTimeoutMs: 5_000, + _fetchCertFingerprint: mockFetchFp, + }); + + await verifier.verify(ISSUER, "USDC"); + + expect(mockFetchFp).toHaveBeenCalledWith(DOMAIN, 5_000); + }); + + it("fingerprint check is case-insensitive (lowercase pin vs uppercase actual)", async () => { + const lowerPin = FINGERPRINT_OK.toLowerCase(); + const verifier = new AnchorVerifier({ + pinnedCertFingerprints: { [DOMAIN]: lowerPin }, + _fetchCertFingerprint: vi.fn().mockResolvedValue(FINGERPRINT_OK), + }); + + // Should NOT throw despite case difference + const result = await verifier.verify(ISSUER, "USDC"); + expect(result.verified).toBe(true); + }); + + it("does not call _fetchCertFingerprint for domains not in the pin map", async () => { + const mockFetchFp = vi.fn().mockResolvedValue(FINGERPRINT_OK); + const verifier = new AnchorVerifier({ + pinnedCertFingerprints: { "other.com": FINGERPRINT_OK }, // different domain + _fetchCertFingerprint: mockFetchFp, + }); + + await verifier.verify(ISSUER, "USDC"); + + expect(mockFetchFp).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Error class shape tests +// --------------------------------------------------------------------------- + +describe("CertificatePinningError (#780)", () => { + it("has the correct name, domain, and fingerprint fields", () => { + const err = new CertificatePinningError(DOMAIN, FINGERPRINT_OK, FINGERPRINT_BAD); + expect(err.name).toBe("CertificatePinningError"); + expect(err.domain).toBe(DOMAIN); + expect(err.expectedFingerprint).toBe(FINGERPRINT_OK); + expect(err.actualFingerprint).toBe(FINGERPRINT_BAD); + }); + + it("is an instance of Error", () => { + expect(new CertificatePinningError(DOMAIN, FINGERPRINT_OK, FINGERPRINT_BAD)).toBeInstanceOf(Error); + }); + + it("message contains the domain", () => { + const err = new CertificatePinningError(DOMAIN, FINGERPRINT_OK, FINGERPRINT_BAD); + expect(err.message).toContain(DOMAIN); + }); +}); + +describe("UnsupportedTomlVersionError (#779)", () => { + it("has the correct name and encounteredVersion", () => { + const err = new UnsupportedTomlVersionError("3.0"); + expect(err.name).toBe("UnsupportedTomlVersionError"); + expect(err.encounteredVersion).toBe("3.0"); + }); + + it("is an instance of Error", () => { + expect(new UnsupportedTomlVersionError("3.0")).toBeInstanceOf(Error); + }); + + it("message contains the encountered version", () => { + expect(new UnsupportedTomlVersionError("3.0").message).toContain("3.0"); + }); +}); diff --git a/test/feeTrend.test.ts b/test/feeTrend.test.ts new file mode 100644 index 0000000..be1be63 --- /dev/null +++ b/test/feeTrend.test.ts @@ -0,0 +1,77 @@ +/** + * Tests for computeMovingAverage (#781). + */ + +import { describe, it, expect } from "vitest"; +import { computeMovingAverage } from "../src/fees/trend.js"; + +describe("computeMovingAverage (#781)", () => { + it("returns an array of the same length as samples", () => { + const result = computeMovingAverage([1, 2, 3, 4, 5], 3); + expect(result).toHaveLength(5); + }); + + it("pads the first windowSize - 1 entries with NaN", () => { + const result = computeMovingAverage([100, 200, 300, 400, 500], 3); + expect(Number.isNaN(result[0])).toBe(true); + expect(Number.isNaN(result[1])).toBe(true); + expect(Number.isNaN(result[2])).toBe(false); + }); + + it("computes the correct SMA values (windowSize=3)", () => { + // [NaN, NaN, (100+200+300)/3, (200+300+400)/3, (300+400+500)/3] + const result = computeMovingAverage([100, 200, 300, 400, 500], 3); + expect(result[2]).toBeCloseTo(200); + expect(result[3]).toBeCloseTo(300); + expect(result[4]).toBeCloseTo(400); + }); + + it("computes correct SMA with windowSize=1 (identity)", () => { + const samples = [10, 20, 30]; + const result = computeMovingAverage(samples, 1); + expect(result).toEqual([10, 20, 30]); + }); + + it("returns a single value equal to the only sample when windowSize equals samples.length", () => { + const result = computeMovingAverage([2, 4, 6], 3); + expect(Number.isNaN(result[0])).toBe(true); + expect(Number.isNaN(result[1])).toBe(true); + expect(result[2]).toBeCloseTo(4); // (2+4+6)/3 + }); + + it("returns an empty array for empty samples", () => { + expect(computeMovingAverage([], 3)).toEqual([]); + }); + + it("throws RangeError when windowSize is 0", () => { + expect(() => computeMovingAverage([1, 2, 3], 0)).toThrowError(RangeError); + expect(() => computeMovingAverage([1, 2, 3], 0)).toThrowError( + /windowSize must be an integer/, + ); + }); + + it("throws RangeError when windowSize is negative", () => { + expect(() => computeMovingAverage([1, 2, 3], -5)).toThrowError(RangeError); + }); + + it("throws RangeError when windowSize is a non-integer", () => { + expect(() => computeMovingAverage([1, 2, 3], 1.5)).toThrowError(RangeError); + }); + + it("is pure — does not mutate the input array", () => { + const samples = [1, 2, 3, 4, 5]; + const copy = [...samples]; + computeMovingAverage(samples, 2); + expect(samples).toEqual(copy); + }); + + it("handles a single-element input", () => { + const result = computeMovingAverage([42], 1); + expect(result).toEqual([42]); + }); + + it("all entries are NaN when windowSize exceeds samples length", () => { + const result = computeMovingAverage([1, 2], 5); + expect(result.every((v) => Number.isNaN(v))).toBe(true); + }); +}); diff --git a/test/xbullAdapter.test.ts b/test/xbullAdapter.test.ts new file mode 100644 index 0000000..70ef755 --- /dev/null +++ b/test/xbullAdapter.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for XBullAdapter listener-leak fix (#840). + * + * Verifies that: + * 1. Repeated connect() calls leave at most one live xBull account-change listener. + * 2. After disconnect(), no listener remains registered with the wallet. + * 3. An account-change event delivered after disconnect() does not modify adapter state. + * 4. Normal single-connect behaviour is unchanged. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { XBullAdapter } from "../src/wallets/adapters/XBullAdapter.js"; + +// --------------------------------------------------------------------------- +// Fake xBull window double +// --------------------------------------------------------------------------- + +/** Tracks all currently-registered onAccountChange handlers. */ +let liveListeners: Array<(pk: string) => void>; +/** Most recently returned unsubscribe function (mirrors what xBull would give back). */ +let lastUnsub: (() => void) | null; + +function makeXBullDouble(publicKey = "GABC123") { + liveListeners = []; + lastUnsub = null; + + return { + connect: vi.fn().mockResolvedValue({ public_key: publicKey }), + sign: vi.fn().mockResolvedValue({ xdr: "signed-xdr" }), + onAccountChange: vi.fn((handler: (pk: string) => void) => { + liveListeners.push(handler); + const unsub = () => { + const idx = liveListeners.indexOf(handler); + if (idx > -1) liveListeners.splice(idx, 1); + }; + lastUnsub = unsub; + return unsub; + }), + }; +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +beforeEach(() => { + // Reset the window double before every test + (globalThis as any).window = { xbull: makeXBullDouble() }; +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("XBullAdapter — listener leak fix (#840)", () => { + it("registers exactly one listener after a single connect()", async () => { + const adapter = new XBullAdapter(); + await adapter.connect(); + + expect(liveListeners).toHaveLength(1); + }); + + it("still has exactly one listener after three consecutive connect() calls", async () => { + const adapter = new XBullAdapter(); + + await adapter.connect(); + await adapter.connect(); + await adapter.connect(); + + expect(liveListeners).toHaveLength(1); + }); + + it("leaves zero listeners after disconnect() following a single connect()", async () => { + const adapter = new XBullAdapter(); + await adapter.connect(); + adapter.disconnect(); + + expect(liveListeners).toHaveLength(0); + }); + + it("leaves zero listeners after disconnect() following three connect() calls", async () => { + const adapter = new XBullAdapter(); + + await adapter.connect(); + await adapter.connect(); + await adapter.connect(); + adapter.disconnect(); + + expect(liveListeners).toHaveLength(0); + }); + + it("does not update currentPublicKey (getAddress) after disconnect(), even when wallet emits accountChanged", async () => { + const adapter = new XBullAdapter(); + const initialKey = "GABC_INITIAL"; + (globalThis as any).window = { xbull: makeXBullDouble(initialKey) }; + + await adapter.connect(); + expect(await adapter.getAddress()).toBe(initialKey); + + adapter.disconnect(); + + // Simulate the wallet emitting an account-change event after disconnect + for (const handler of [...liveListeners]) { + handler("GC_AFTER_DISCONNECT"); + } + + // currentPublicKey must remain null — getAddress() falls back to connect() + // which would call xbull.connect() again; we just confirm no stale key leaks. + expect(liveListeners).toHaveLength(0); + }); + + it("invokes onAccountChange handlers when the wallet emits a change after connect()", async () => { + const adapter = new XBullAdapter(); + await adapter.connect(); + + const handler = vi.fn(); + adapter.onAccountChange(handler); + + // Simulate wallet emitting account change + const newKey = "GNEW_KEY_456"; + liveListeners[0]!(newKey); + + expect(handler).toHaveBeenCalledWith(newKey); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("does not invoke disconnected onAccountChange handlers after unsubscribe", async () => { + const adapter = new XBullAdapter(); + await adapter.connect(); + + const handler = vi.fn(); + const unsub = adapter.onAccountChange(handler); + unsub(); + + liveListeners[0]!("GNEW_KEY"); + + expect(handler).not.toHaveBeenCalled(); + }); +});