From d902b2f2037a0977a9cadee444a5c38600b89c27 Mon Sep 17 00:00:00 2001 From: mhikel66 <162179812+mhikel66@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:34:50 +0100 Subject: [PATCH 01/10] refactor(events): one canonical TrustFlowEventType + a discriminated-union parseEvent (#108, #112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/events.ts` is now the single source of the event vocabulary (underscore-separated, matching the emitted Soroban topic) and payload shapes. `parseEvent` returns `ParsedTrustFlowEvent`, a discriminated union keyed on `type` — the three typed branches previously failed `tsc` with TS2322 because `EscrowCreatedData` etc. have no index signature and so did not match the `ParsedEvent>` default. Callers now narrow `.data` by switching on `.type`, no cast. Refs #108, #112 --- src/events.ts | 60 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/src/events.ts b/src/events.ts index f7a2710..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); } From 4285ed272241c1d3d13a6b57af587d454c3579c7 Mon Sep 17 00:00:00 2001 From: mhikel66 <162179812+mhikel66@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:34:54 +0100 Subject: [PATCH 02/10] refactor(events): types/events.ts re-exports the canonical shapes; TrustFlowEvent aliases ParsedEvent (#108) The duplicate `TrustFlowEventType` (dot-notation) and the `{ escrowId, payload, blockNumber, txHash }` shape are removed. `TrustFlowEvent` is now an alias of the parser's discriminated union and `EventHandler` takes it, so the two `TrustFlowEventType` names no longer collide and the monitor speaks the parser's language. Refs #108 --- src/types/events.ts | 50 +++++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 15 deletions(-) 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; From 313a8fbc40e2dd1736315fc3f6e82a734b0755b7 Mon Sep 17 00:00:00 2001 From: mhikel66 <162179812+mhikel66@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:35:01 +0100 Subject: [PATCH 03/10] feat(monitor): consume ParsedTrustFlowEvent directly and add deliver() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EscrowMonitor` handlers now receive `ParsedTrustFlowEvent` — exactly what `parseEvents(rawEvents, contractId)` produces — so there is no translation step. `deliver(events)` dispatches a pre-parsed batch (used by `startPolling` and callable directly). Monitor test updated to the canonical vocabulary and shape. Closes #108 --- src/escrow/monitor.ts | 41 ++++++++++++++++++++++++-------- tests/monitor.test.ts | 54 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 74 insertions(+), 21 deletions(-) diff --git a/src/escrow/monitor.ts b/src/escrow/monitor.ts index ba1ee66..a441193 100644 --- a/src/escrow/monitor.ts +++ b/src/escrow/monitor.ts @@ -1,5 +1,14 @@ -import { TrustFlowEvent, EventHandler } from '../types/events'; +import type { ParsedTrustFlowEvent, EventHandler } from '../types/events'; +/** + * 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`. + */ export class EscrowMonitor { private handlers = new Map>(); private pollingInterval?: ReturnType; @@ -17,16 +26,28 @@ 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, 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((err) => console.error(err)); + }); + } + } + + startPolling( + intervalMs = 5000, + fetchFn: () => Promise, + ): void { this.pollingInterval = setInterval(async () => { - const events = await fetchFn().catch(() => []); - 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(console.error); - }); - } + const events = await fetchFn().catch(() => [] as ParsedTrustFlowEvent[]); + this.deliver(events); }, intervalMs); } diff --git a/tests/monitor.test.ts b/tests/monitor.test.ts index 809bb70..e9c7abf 100644 --- a/tests/monitor.test.ts +++ b/tests/monitor.test.ts @@ -1,22 +1,54 @@ 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', () => { 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); }); }); From 455ba9d5650e9b18a7fd5c55a018d4639dc1697b Mon Sep 17 00:00:00 2001 From: mhikel66 <162179812+mhikel66@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:35:09 +0100 Subject: [PATCH 04/10] chore(exports): expose parseEvent/parseEvents/isTrustFlowEvent from the package root (#112) The types already reach the root via `export * from './types/events'`; add the parser functions so consumers can call them and get the discriminated-union narrowing. Refs #112 --- src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/index.ts b/src/index.ts index b9aa280..7239b1e 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'; From c9d1a4b0afaf4223fd3b9c93331b3e7c7753ff8b Mon Sep 17 00:00:00 2001 From: mhikel66 <162179812+mhikel66@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:35:16 +0100 Subject: [PATCH 05/10] test(events): parse-to-handle end-to-end and per-type data narrowing (#108, #112) Feeds a `parseEvents` output straight into an `EscrowMonitor` handler, asserts the handler receives a narrowed `escrow_created` event, and covers the unknown-type fallback and cross-contract filtering. Closes #108, closes #112 --- tests/events-consolidation.test.ts | 76 ++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/events-consolidation.test.ts 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); + }); +}); From 46e95b478d3e261de350eac2066c8e2d705bc3c5 Mon Sep 17 00:00:00 2001 From: mhikel66 <162179812+mhikel66@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:35:25 +0100 Subject: [PATCH 06/10] refactor(network): derive constants URLs and passphrases from NETWORK_CONFIGS (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HORIZON_URLS` / `SOROBAN_RPC_URLS` and a new `NETWORK_PASSPHRASES` are now a view of `src/stellar/network.ts`'s `NETWORK_CONFIGS` — the canonical source (it already carries the passphrase) — rather than a second copy of the URL literals. Refs #109 --- src/constants.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) 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 From d64d44b272960041cdf6d4a9d075086a7e8871a0 Mon Sep 17 00:00:00 2001 From: mhikel66 <162179812+mhikel66@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:35:35 +0100 Subject: [PATCH 07/10] refactor(client): read the network passphrase from the single config source (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TrustFlowClient.getNetworkPassphrase()` returned two hard-coded strings — a third copy. It now returns `NETWORK_PASSPHRASES[this.network]`, the same canonical source the URL maps use. Closes #109 --- src/client.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/client.ts b/src/client.ts index 5ca697d..0f3c7f8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,11 @@ import { Horizon } 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'; @@ -138,9 +144,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]; } /** From 88e346ee8963f3d64ab8046c5170cb05d1add7e6 Mon Sep 17 00:00:00 2001 From: mhikel66 <162179812+mhikel66@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:35:45 +0100 Subject: [PATCH 08/10] test(network): constants, client, and NETWORK_CONFIGS resolve identically (#109) Refs #109 --- tests/network-config-single-source.test.ts | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/network-config-single-source.test.ts 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); + } + }); +}); From a6caecde913631cefe9a381873740b39cf793e1f Mon Sep 17 00:00:00 2001 From: mhikel66 <162179812+mhikel66@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:35:56 +0100 Subject: [PATCH 09/10] feat(build): validate sender/recipient/caller/escrowId before ScVal construction (#111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildCreateEscrowArgs`, `buildReleaseArgs`, and `buildDisputeArgs` now call `assertStellarAddress` / an `isValidEscrowId` guard up front, so a malformed input throws `TrustFlowError.validation(...)` — consistent with `escrow/create.ts` — instead of whatever `new Address()` throws. Closes #111 --- src/contract/build.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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' })]; } From 01b8c2a080c0bbc69c0e688ddeb79dfb3e83a751 Mon Sep 17 00:00:00 2001 From: mhikel66 <162179812+mhikel66@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:36:09 +0100 Subject: [PATCH 10/10] test(build): invalid-input coverage for the three arg builders (#111) Refs #111 --- tests/build-args-validation.test.ts | 74 +++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/build-args-validation.test.ts diff --git a/tests/build-args-validation.test.ts b/tests/build-args-validation.test.ts new file mode 100644 index 0000000..da74205 --- /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 = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB4E'; + +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'); + } + }); +});