diff --git a/src/client.ts b/src/client.ts index bffa830..d3db75a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,11 @@ import { Horizon, xdr } from '@stellar/stellar-sdk'; -import { HORIZON_URLS, SOROBAN_RPC_URLS, DEFAULT_NETWORK, SDK_VERSION } from './constants'; +import { + HORIZON_URLS, + SOROBAN_RPC_URLS, + NETWORK_PASSPHRASES, + DEFAULT_NETWORK, + SDK_VERSION, +} from './constants'; import { TrustFlowError } from './errors'; import type { Network, ClientConfig } from './types'; import { IPFSStorage } from './storage'; @@ -140,9 +146,9 @@ export class TrustFlowClient { * @returns Network passphrase string */ getNetworkPassphrase(): string { - return this.network === 'TESTNET' - ? 'Test SDF Network ; September 2015' - : 'Public Global Stellar Network ; September 2015'; + // Reads the same canonical source as HORIZON_URLS / SOROBAN_RPC_URLS + // (NETWORK_CONFIGS via constants) rather than a third inline copy (#109). + return NETWORK_PASSPHRASES[this.network]; } /** diff --git a/src/constants.ts b/src/constants.ts index c018c67..088741f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,15 +1,26 @@ import type { Network } from './types'; +import { NETWORK_CONFIGS } from './stellar/network'; export const DEFAULT_NETWORK: Network = 'TESTNET'; +/** + * URLs and passphrases are a *view* of `NETWORK_CONFIGS` + * (`src/stellar/network.ts`) — the single canonical network-config source + * (#109) — not a second copy of the literals. + */ export const HORIZON_URLS: Record = { - TESTNET: 'https://horizon-testnet.stellar.org', - MAINNET: 'https://horizon.stellar.org', + TESTNET: NETWORK_CONFIGS.TESTNET.horizonUrl, + MAINNET: NETWORK_CONFIGS.MAINNET.horizonUrl, }; export const SOROBAN_RPC_URLS: Record = { - TESTNET: 'https://soroban-testnet.stellar.org', - MAINNET: 'https://soroban.stellar.org', + TESTNET: NETWORK_CONFIGS.TESTNET.rpcUrl, + MAINNET: NETWORK_CONFIGS.MAINNET.rpcUrl, +}; + +export const NETWORK_PASSPHRASES: Record = { + TESTNET: NETWORK_CONFIGS.TESTNET.passphrase, + MAINNET: NETWORK_CONFIGS.MAINNET.passphrase, }; export const ESCROW_MIN_AMOUNT_STROOPS = 1_000_000n; // 0.1 XLM diff --git a/src/contract/build.ts b/src/contract/build.ts index c1b9eb9..7d67dc0 100644 --- a/src/contract/build.ts +++ b/src/contract/build.ts @@ -1,8 +1,20 @@ import { Address, nativeToScVal } from '@stellar/stellar-sdk'; import type { CreateEscrowParams } from '../types'; import type { VotePayload } from '../types/juror'; +import { assertStellarAddress, isValidEscrowId } from '../utils/validation'; +import { TrustFlowError } from '../errors'; + +function assertEscrowId(escrowId: string): void { + if (!isValidEscrowId(escrowId)) { + throw TrustFlowError.validation('escrowId', `not a usable escrow id: "${escrowId}"`); + } +} export function buildCreateEscrowArgs(params: CreateEscrowParams): unknown[] { + // Reject a malformed address here (#111) so callers get a + // TrustFlowError.validation(...) instead of whatever `new Address()` throws. + assertStellarAddress(params.sender, 'sender'); + assertStellarAddress(params.recipient, 'recipient'); return [ new Address(params.sender).toScVal(), new Address(params.recipient).toScVal(), @@ -12,6 +24,8 @@ export function buildCreateEscrowArgs(params: CreateEscrowParams): unknown[] { } export function buildReleaseArgs(escrowId: string, caller: string): unknown[] { + assertEscrowId(escrowId); + assertStellarAddress(caller, 'caller'); return [nativeToScVal(escrowId, { type: 'string' }), new Address(caller).toScVal()]; } @@ -53,6 +67,7 @@ export function buildFundArgs( } export function buildDisputeArgs(escrowId: string, reason: string): unknown[] { + assertEscrowId(escrowId); return [nativeToScVal(escrowId, { type: 'string' }), nativeToScVal(reason, { type: 'string' })]; } diff --git a/src/escrow/monitor.ts b/src/escrow/monitor.ts index c712e61..b1b05c4 100644 --- a/src/escrow/monitor.ts +++ b/src/escrow/monitor.ts @@ -1,4 +1,4 @@ -import { TrustFlowEvent, EventHandler } from '../types/events'; +import type { ParsedTrustFlowEvent, EventHandler } from '../types/events'; import { logger } from '../utils/logger'; /** @@ -14,7 +14,7 @@ export interface EscrowMonitorErrorContext { /** The phase of polling in which the error occurred. */ phase: EscrowMonitorErrorPhase; /** The event that triggered the failing handler, when `phase === 'handler'`. */ - event?: TrustFlowEvent; + event?: ParsedTrustFlowEvent; /** The handler that threw, when `phase === 'handler'`. */ handler?: EventHandler; } @@ -29,6 +29,18 @@ export type EscrowMonitorOnError = ( context: EscrowMonitorErrorContext ) => void; +/** + * Subscribes handlers to parsed TrustFlow events and dispatches them. + * + * Events come in as {@link ParsedTrustFlowEvent} — exactly what + * `parseEvents(rawEvents, contractId)` (`src/events.ts`) produces — so a + * caller can wire the SDK's own parser straight into this without any + * translation step (#108). Handlers narrow `event.data` by switching on + * `event.type`. + * + * Register {@link EscrowMonitor.onError} to observe fetch/handler failures + * that are otherwise only surfaced through the SDK logger (#112). + */ export class EscrowMonitor { private handlers = new Map>(); private pollingInterval?: ReturnType; @@ -62,9 +74,31 @@ export class EscrowMonitor { return this; } - startPolling(intervalMs = 5000, fetchFn: () => Promise): void { + /** + * Dispatch a batch of already-parsed events to their registered handlers + * (plus any `'*'` wildcard handlers). Handler rejections are logged and + * forwarded to {@link EscrowMonitor.onError}, not thrown, so one bad handler + * can't stop the rest. + */ + deliver(events: ParsedTrustFlowEvent[]): void { + for (const event of events) { + const handlers = this.handlers.get(event.type) ?? new Set(); + const wildcards = this.handlers.get('*') ?? new Set(); + [...handlers, ...wildcards].forEach((h) => { + Promise.resolve(h(event)).catch((error: unknown) => { + logger.error('Event handler failed', { error, event }); + this.errorCallback?.(error, { phase: 'handler', event, handler: h }); + }); + }); + } + } + + startPolling( + intervalMs = 5000, + fetchFn: () => Promise + ): void { this.pollingInterval = setInterval(async () => { - let events: TrustFlowEvent[]; + let events: ParsedTrustFlowEvent[]; try { events = await fetchFn(); } catch (error) { @@ -72,16 +106,7 @@ export class EscrowMonitor { this.errorCallback?.(error, { phase: 'fetch' }); return; } - for (const event of events) { - const handlers = this.handlers.get(event.type) ?? new Set(); - const wildcards = this.handlers.get('*') ?? new Set(); - [...handlers, ...wildcards].forEach((h) => { - Promise.resolve(h(event)).catch((error: unknown) => { - logger.error('Event handler failed', { error, event }); - this.errorCallback?.(error, { phase: 'handler', event, handler: h }); - }); - }); - } + this.deliver(events); }, intervalMs); } diff --git a/src/events.ts b/src/events.ts index c3fd6ab..abd1745 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,6 +1,16 @@ /** * Event parsing utilities for TrustFlow contract events (#40). * Parse raw Soroban contract events into typed structures. + * + * This module is the **single source of truth** for TrustFlow's event + * vocabulary and payload shapes (#108). `src/types/events.ts` re-exports these + * types and adds the `EscrowMonitor`-facing aliases; the parser output + * (`ParsedTrustFlowEvent`) is what `EscrowMonitor` handlers receive, so there + * is no adapter step between the two. + * + * The canonical event-name convention is underscore-separated + * (`escrow_created`), matching the Soroban `Symbol` topic the contract emits + * and what `decodeScVal` reads off `topic[0]`. */ export type TrustFlowEventType = @@ -22,12 +32,21 @@ export interface RawContractEvent { value: string; } -export interface ParsedEvent { - type: TrustFlowEventType; +/** Fields common to every parsed event, regardless of `type`. */ +export interface ParsedEventBase { contractId: string; ledger: number; timestamp: string; id: string; +} + +/** + * A parsed event with an unspecified payload. Kept for callers that iterate + * events generically; use {@link ParsedTrustFlowEvent} (or `parseEvent`'s + * return type directly) when you want `data` narrowed by `type`. + */ +export interface ParsedEvent> extends ParsedEventBase { + type: TrustFlowEventType; data: T; } @@ -50,6 +69,25 @@ export interface DisputeRaisedData { reason: string; } +/** + * Discriminated union over `type` (#112). `parseEvent` returns this, so a + * `switch`/`if` on `.type` narrows `.data` to the right shape with no cast — + * the three typed branches previously failed `tsc` because + * `EscrowCreatedData` etc. have no index signature and so did not match the + * `ParsedEvent>` default. + */ +export type ParsedTrustFlowEvent = + | (ParsedEventBase & { type: 'escrow_created'; data: EscrowCreatedData }) + | (ParsedEventBase & { type: 'escrow_released'; data: EscrowReleasedData }) + | (ParsedEventBase & { type: 'dispute_raised'; data: DisputeRaisedData }) + | (ParsedEventBase & { + type: Exclude< + TrustFlowEventType, + 'escrow_created' | 'escrow_released' | 'dispute_raised' + >; + data: Record; + }); + /** Decode a Soroban XDR value string to a plain JS string */ function decodeScVal(xdr: string): string { // In production, use @stellar/stellar-sdk ScVal.fromXDR().value() @@ -72,14 +110,14 @@ export function isTrustFlowEvent(event: RawContractEvent, contractId: string): b } /** Parse a raw Soroban contract event into a typed TrustFlow event */ -export function parseEvent(event: RawContractEvent): ParsedEvent | null { +export function parseEvent(event: RawContractEvent): ParsedTrustFlowEvent | null { if (!event.topic || event.topic.length === 0) { return null; } const eventType = decodeScVal(event.topic[0]) as TrustFlowEventType; - const base = { + const base: ParsedEventBase = { contractId: event.contractId, ledger: event.ledger, timestamp: event.ledgerClosedAt, @@ -96,7 +134,7 @@ export function parseEvent(event: RawContractEvent): ParsedEvent | null { sender: decodeScVal(event.topic[2] ?? ''), recipient: decodeScVal(event.topic[3] ?? ''), amount: BigInt(decodeScVal(event.value) || '0'), - } as EscrowCreatedData, + }, }; case 'escrow_released': @@ -107,7 +145,7 @@ export function parseEvent(event: RawContractEvent): ParsedEvent | null { escrowId: decodeScVal(event.topic[1] ?? ''), recipient: decodeScVal(event.topic[2] ?? ''), amount: BigInt(decodeScVal(event.value) || '0'), - } as EscrowReleasedData, + }, }; case 'dispute_raised': @@ -118,18 +156,22 @@ export function parseEvent(event: RawContractEvent): ParsedEvent | null { escrowId: decodeScVal(event.topic[1] ?? ''), raisedBy: decodeScVal(event.topic[2] ?? ''), reason: decodeScVal(event.value), - } as DisputeRaisedData, + }, }; default: + // `eventType` is narrowed here to the event types not handled above. return { ...base, type: eventType, data: {} }; } } /** Parse an array of raw events, filtering nulls and non-TrustFlow events */ -export function parseEvents(events: RawContractEvent[], contractId: string): ParsedEvent[] { +export function parseEvents( + events: RawContractEvent[], + contractId: string, +): ParsedTrustFlowEvent[] { return events .filter((e) => isTrustFlowEvent(e, contractId)) .map(parseEvent) - .filter((e): e is ParsedEvent => e !== null); + .filter((e): e is ParsedTrustFlowEvent => e !== null); } diff --git a/src/index.ts b/src/index.ts index 5c58a5d..a24b571 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,9 @@ export * from './types'; export * from './types/index'; export * from './types/contract'; export * from './types/events'; +// Parser functions (the types come via ./types/events above) so consumers can +// call parseEvent/parseEvents and get discriminated-union narrowing (#112). +export { parseEvent, parseEvents, isTrustFlowEvent } from './events'; export * from './types/multisig'; export * from './types/juror'; export * from './types/profile'; diff --git a/src/types/events.ts b/src/types/events.ts index abe68b3..da43c08 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -1,17 +1,37 @@ -export type TrustFlowEventType = - | 'escrow.created' - | 'escrow.released' - | 'escrow.cancelled' - | 'dispute.raised' - | 'dispute.resolved'; +/** + * TrustFlow event types (#108). + * + * The single source of truth is `src/events.ts` — it owns the parser that + * decodes Soroban XDR topics into these shapes. This module re-exports them + * (the package root re-exports this file) and adds the `EscrowMonitor`-facing + * aliases. + * + * The `escrow.created` dot-notation and the `{ escrowId, payload, blockNumber, + * txHash }` shape that used to live here are gone: they duplicated — and + * conflicted with — the parser's `escrow_created` / `ParsedEvent` output, so + * `parseEvent`'s result could not be fed into an `EscrowMonitor` handler + * without a translation step that never existed. + */ -export interface TrustFlowEvent { - type: TrustFlowEventType; - escrowId: string; - payload: T; - blockNumber: number; - txHash: string; - timestamp: number; -} +export type { + TrustFlowEventType, + RawContractEvent, + ParsedEventBase, + ParsedEvent, + ParsedTrustFlowEvent, + EscrowCreatedData, + EscrowReleasedData, + DisputeRaisedData, +} from '../events'; -export type EventHandler = (event: TrustFlowEvent) => void | Promise; +import type { ParsedTrustFlowEvent } from '../events'; + +/** + * The event object an `EscrowMonitor` handler receives — now an alias of the + * parser's discriminated union, so `parseEvents(...)` output flows straight + * into `EscrowMonitor.deliver` / `startPolling`'s `fetchFn` with no adapter + * (#108). Narrow on `event.type` to get a typed `event.data`. + */ +export type TrustFlowEvent = ParsedTrustFlowEvent; + +export type EventHandler = (event: ParsedTrustFlowEvent) => void | Promise; diff --git a/tests/build-args-validation.test.ts b/tests/build-args-validation.test.ts new file mode 100644 index 0000000..c1cdd6d --- /dev/null +++ b/tests/build-args-validation.test.ts @@ -0,0 +1,74 @@ +import { + buildCreateEscrowArgs, + buildReleaseArgs, + buildDisputeArgs, +} from '../src/contract/build'; +import { TrustFlowError } from '../src/errors'; +import type { CreateEscrowParams } from '../src/types'; + +const VALID_ADDR = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; +const OTHER_ADDR = 'GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR'; + +function createParams(over: Partial = {}): CreateEscrowParams { + return { + sender: VALID_ADDR, + recipient: OTHER_ADDR, + amountStroops: 2_000_000n, + durationBlocks: 100, + ...over, + } as CreateEscrowParams; +} + +/** + * #111 — the three arg builders must reject a malformed address / escrowId with + * a `TrustFlowError.validation(...)` before touching `new Address(...)`. + */ +describe('build.ts address / id validation (#111)', () => { + describe('buildCreateEscrowArgs', () => { + it('accepts valid addresses', () => { + expect(() => buildCreateEscrowArgs(createParams())).not.toThrow(); + }); + it('rejects an invalid sender with a TrustFlowError', () => { + expect(() => buildCreateEscrowArgs(createParams({ sender: 'not-an-address' }))).toThrow( + TrustFlowError, + ); + }); + it('rejects an invalid recipient with a TrustFlowError', () => { + expect(() => buildCreateEscrowArgs(createParams({ recipient: 'GXXX' }))).toThrow( + TrustFlowError, + ); + }); + }); + + describe('buildReleaseArgs', () => { + it('accepts a valid escrowId + caller', () => { + expect(() => buildReleaseArgs('esc-1', VALID_ADDR)).not.toThrow(); + }); + it('rejects an empty escrowId', () => { + expect(() => buildReleaseArgs('', VALID_ADDR)).toThrow(TrustFlowError); + expect(() => buildReleaseArgs(' ', VALID_ADDR)).toThrow(TrustFlowError); + }); + it('rejects an invalid caller', () => { + expect(() => buildReleaseArgs('esc-1', 'nope')).toThrow(TrustFlowError); + }); + }); + + describe('buildDisputeArgs', () => { + it('accepts a valid escrowId', () => { + expect(() => buildDisputeArgs('esc-1', 'goods not delivered')).not.toThrow(); + }); + it('rejects an empty escrowId', () => { + expect(() => buildDisputeArgs('', 'reason')).toThrow(TrustFlowError); + }); + }); + + it('validation errors are TrustFlowError.validation, not a raw @stellar/stellar-sdk error', () => { + try { + buildCreateEscrowArgs(createParams({ sender: 'bad' })); + throw new Error('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(TrustFlowError); + expect((e as TrustFlowError).code).toBe('VALIDATION_ERROR'); + } + }); +}); diff --git a/tests/contract.test.ts b/tests/contract.test.ts index e19430b..a7a50a1 100644 --- a/tests/contract.test.ts +++ b/tests/contract.test.ts @@ -47,8 +47,8 @@ describe('contract module', () => { describe('build.ts', () => { it('buildCreateEscrowArgs returns valid arguments', () => { const args = buildCreateEscrowArgs({ - sender: 'GBM...', - recipient: 'GBA...', + sender: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + recipient: 'GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR', amountStroops: 1000n, durationBlocks: 100, }); @@ -56,7 +56,7 @@ describe('contract module', () => { }); it('buildReleaseArgs returns valid arguments', () => { - const args = buildReleaseArgs('escrow1', 'GBM...'); + const args = buildReleaseArgs('escrow1', 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'); expect(args.length).toBe(2); }); diff --git a/tests/events-consolidation.test.ts b/tests/events-consolidation.test.ts new file mode 100644 index 0000000..4b66c19 --- /dev/null +++ b/tests/events-consolidation.test.ts @@ -0,0 +1,76 @@ +import { parseEvent, parseEvents } from '../src/events'; +import type { RawContractEvent, ParsedTrustFlowEvent } from '../src/events'; +import { EscrowMonitor } from '../src/escrow/monitor'; + +/** Build the base64 an `SCV_STRING` ScVal decodes to `s` (prefix byte 0x0e, 4 skipped bytes). */ +function scStr(s: string): string { + return Buffer.concat([ + Buffer.from([0x0e, 0, 0, 0, s.length]), + Buffer.from(s, 'utf8'), + ]).toString('base64'); +} + +const CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; + +function rawCreated(): RawContractEvent { + return { + type: 'contract', + ledger: 42, + ledgerClosedAt: '2024-01-01T00:00:00Z', + contractId: CONTRACT_ID, + id: 'ev-1', + pagingToken: 'pt', + value: '5000000', + topic: [scStr('escrow_created'), scStr('esc-1'), scStr('GSENDER'), scStr('GRECIPIENT')], + }; +} + +describe('event consolidation (#108, #112)', () => { + it('parseEvent returns a discriminated union that narrows data on type (#112)', () => { + const event = parseEvent(rawCreated()); + expect(event).not.toBeNull(); + expect(event!.type).toBe('escrow_created'); + + if (event && event.type === 'escrow_created') { + // `data` is EscrowCreatedData here — no cast, and these members exist. + expect(event.data.escrowId).toBe('esc-1'); + expect(event.data.sender).toBe('GSENDER'); + expect(event.data.recipient).toBe('GRECIPIENT'); + expect(event.data.amount).toBe(5_000_000n); + } else { + throw new Error('expected an escrow_created event'); + } + }); + + it('an unknown/unhandled event type falls back to an empty record payload', () => { + const raw = rawCreated(); + raw.topic = [scStr('milestone_completed'), scStr('esc-9')]; + const event = parseEvent(raw); + expect(event?.type).toBe('milestone_completed'); + expect(event?.data).toEqual({}); + }); + + it('parseEvents output flows straight into an EscrowMonitor handler (#108)', async () => { + const monitor = new EscrowMonitor(); + const seen: ParsedTrustFlowEvent[] = []; + monitor.on('escrow_created', (e) => { + seen.push(e); + }); + + // No adapter between the two — parseEvents returns exactly what deliver() takes. + monitor.deliver(parseEvents([rawCreated()], CONTRACT_ID)); + await Promise.resolve(); + + expect(seen).toHaveLength(1); + expect(seen[0].type).toBe('escrow_created'); + if (seen[0].type === 'escrow_created') { + expect(seen[0].data.amount).toBe(5_000_000n); + } + }); + + it('parseEvents filters events from other contracts', () => { + const mine = rawCreated(); + const theirs = { ...rawCreated(), contractId: 'COTHER', id: 'ev-2' }; + expect(parseEvents([mine, theirs], CONTRACT_ID)).toHaveLength(1); + }); +}); diff --git a/tests/monitor.test.ts b/tests/monitor.test.ts index 60b8b45..ff49d8e 100644 --- a/tests/monitor.test.ts +++ b/tests/monitor.test.ts @@ -1,4 +1,16 @@ import { EscrowMonitor } from '../src/escrow/monitor'; +import type { ParsedTrustFlowEvent } from '../src/events'; + +function createdEvent(escrowId: string): ParsedTrustFlowEvent { + return { + type: 'escrow_created', + contractId: 'CABC', + ledger: 1, + timestamp: '2024-01-01T00:00:00Z', + id: `ev-${escrowId}`, + data: { escrowId, sender: 'GS', recipient: 'GR', amount: 1n }, + }; +} describe('EscrowMonitor', () => { beforeEach(() => { @@ -11,21 +23,41 @@ describe('EscrowMonitor', () => { it('registers and fires event handlers', async () => { const monitor = new EscrowMonitor(); - let received: any = null; - monitor.on('escrow.created', async e => { received = e; }); - // Simulate event - const event = { type: 'escrow.created' as const, escrowId: '1', payload: {}, blockNumber: 1, txHash: 'abc', timestamp: Date.now() }; - const handlers = (monitor as any).handlers.get('escrow.created') as Set; - await Promise.all([...handlers].map((h: any) => h(event))); - expect(received?.escrowId).toBe('1'); + let received: ParsedTrustFlowEvent | null = null; + monitor.on('escrow_created', async (e) => { + received = e; + }); + + monitor.deliver([createdEvent('1')]); + await Promise.resolve(); + + expect(received).not.toBeNull(); + expect(received!.type).toBe('escrow_created'); + if (received!.type === 'escrow_created') { + expect(received!.data.escrowId).toBe('1'); + } + }); + + it('fires wildcard handlers too', async () => { + const monitor = new EscrowMonitor(); + const seen: string[] = []; + monitor.on('*', (e) => { + seen.push(e.type); + }); + + monitor.deliver([createdEvent('1'), createdEvent('2')]); + await Promise.resolve(); + + expect(seen).toEqual(['escrow_created', 'escrow_created']); }); it('removes handler with off()', () => { const monitor = new EscrowMonitor(); - const h = () => {}; - monitor.on('escrow.created', h); - monitor.off('escrow.created', h); - expect((monitor as any).handlers.get('escrow.created')?.size).toBe(0); + const h = (): void => {}; + monitor.on('escrow_created', h); + monitor.off('escrow_created', h); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((monitor as any).handlers.get('escrow_created')?.size).toBe(0); }); describe('onError', () => { diff --git a/tests/network-config-single-source.test.ts b/tests/network-config-single-source.test.ts new file mode 100644 index 0000000..1d5cde1 --- /dev/null +++ b/tests/network-config-single-source.test.ts @@ -0,0 +1,44 @@ +import { NETWORK_CONFIGS, getNetworkConfig } from '../src/stellar/network'; +import { HORIZON_URLS, SOROBAN_RPC_URLS, NETWORK_PASSPHRASES } from '../src/constants'; +import { TrustFlowClient } from '../src/client'; +import type { Network } from '../src/types'; + +const NETWORKS: Network[] = ['TESTNET', 'MAINNET']; + +/** + * #109 — network URLs and passphrases are defined exactly once + * (`NETWORK_CONFIGS`); `src/constants.ts` and `TrustFlowClient` must resolve to + * the same values by construction. + */ +describe('single-source network config (#109)', () => { + it('constants derive from NETWORK_CONFIGS', () => { + for (const network of NETWORKS) { + expect(HORIZON_URLS[network]).toBe(NETWORK_CONFIGS[network].horizonUrl); + expect(SOROBAN_RPC_URLS[network]).toBe(NETWORK_CONFIGS[network].rpcUrl); + expect(NETWORK_PASSPHRASES[network]).toBe(NETWORK_CONFIGS[network].passphrase); + } + }); + + it('TrustFlowClient.getNetworkPassphrase() reads from the same source', () => { + for (const network of NETWORKS) { + const client = new TrustFlowClient({ + contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + network, + }); + expect(client.getNetworkPassphrase()).toBe(NETWORK_CONFIGS[network].passphrase); + } + }); + + it('getNetworkConfig returns the canonical entry', () => { + for (const network of NETWORKS) { + expect(getNetworkConfig(network)).toBe(NETWORK_CONFIGS[network]); + } + }); + + it('the two networks have distinct, non-empty passphrases', () => { + expect(NETWORK_CONFIGS.TESTNET.passphrase).not.toBe(NETWORK_CONFIGS.MAINNET.passphrase); + for (const network of NETWORKS) { + expect(NETWORK_CONFIGS[network].passphrase.length).toBeGreaterThan(0); + } + }); +});