Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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];
}

/**
Expand Down
19 changes: 15 additions & 4 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -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<Network, string> = {
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<Network, string> = {
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<Network, string> = {
TESTNET: NETWORK_CONFIGS.TESTNET.passphrase,
MAINNET: NETWORK_CONFIGS.MAINNET.passphrase,
};

export const ESCROW_MIN_AMOUNT_STROOPS = 1_000_000n; // 0.1 XLM
Expand Down
15 changes: 15 additions & 0 deletions src/contract/build.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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()];
}

Expand Down Expand Up @@ -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' })];
}

Expand Down
53 changes: 39 additions & 14 deletions src/escrow/monitor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TrustFlowEvent, EventHandler } from '../types/events';
import type { ParsedTrustFlowEvent, EventHandler } from '../types/events';
import { logger } from '../utils/logger';

/**
Expand All @@ -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;
}
Expand All @@ -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<string, Set<EventHandler>>();
private pollingInterval?: ReturnType<typeof setInterval>;
Expand Down Expand Up @@ -62,26 +74,39 @@ export class EscrowMonitor {
return this;
}

startPolling(intervalMs = 5000, fetchFn: () => Promise<TrustFlowEvent[]>): 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<EventHandler>();
const wildcards = this.handlers.get('*') ?? new Set<EventHandler>();
[...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<ParsedTrustFlowEvent[]>
): void {
this.pollingInterval = setInterval(async () => {
let events: TrustFlowEvent[];
let events: ParsedTrustFlowEvent[];
try {
events = await fetchFn();
} catch (error) {
logger.error('Failed to fetch events during polling', error);
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);
}

Expand Down
60 changes: 51 additions & 9 deletions src/events.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand All @@ -22,12 +32,21 @@ export interface RawContractEvent {
value: string;
}

export interface ParsedEvent<T = unknown> {
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<T = Record<string, unknown>> extends ParsedEventBase {
type: TrustFlowEventType;
data: T;
}

Expand All @@ -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<Record<string, unknown>>` 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<string, unknown>;
});

/** 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()
Expand All @@ -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,
Expand All @@ -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':
Expand All @@ -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':
Expand All @@ -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);
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
50 changes: 35 additions & 15 deletions src/types/events.ts
Original file line number Diff line number Diff line change
@@ -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<T = unknown> {
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<T = unknown> = (event: TrustFlowEvent<T>) => void | Promise<void>;
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<void>;
Loading