Skip to content

Commit 3fff5a8

Browse files
authored
Merge branch 'main' into fix/graphql-indexer-network-io
2 parents 3cfd94d + bcec2c9 commit 3fff5a8

6 files changed

Lines changed: 162 additions & 18 deletions

‎src/governor.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ function parseGovernorConfig(val: xdr.ScVal): GovernorConfig {
6868
feeBps: m['fee_bps']?.u32() ?? 0,
6969
minDurationSeconds: m['min_duration_seconds'] ? Number(scValToU64(m['min_duration_seconds'])) : 0,
7070
maxRatePerSecond: m['max_rate_per_second'] ? scValToI128(m['max_rate_per_second']) : 0n,
71+
// exactOptionalPropertyTypes forbids assigning `undefined` to an optional
72+
// key directly — the key must be entirely absent instead, hence the
73+
// conditional spreads rather than `feeRecipient: ... ? ... : undefined`.
74+
...(m['fee_recipient'] ? { feeRecipient: Address.fromScVal(m['fee_recipient']).toString() } : {}),
75+
...(m['factory_address'] ? { factoryAddress: Address.fromScVal(m['factory_address']).toString() } : {}),
7176
};
7277
if (m['fee_recipient']) {
7378
config.feeRecipient = Address.fromScVal(m['fee_recipient']).toString();

‎src/relayer/WebSocketRelayer.ts‎

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export interface WebSocketMessage {
55
}
66

77
export type MessageHandler = (msg: WebSocketMessage) => Promise<void> | void;
8+
export type StateChangeHandler = (transition: RelayerStateTransition, state: RelayerState) => void;
89

910
export interface RelayerState {
1011
connected: boolean;
@@ -15,23 +16,35 @@ export interface RelayerState {
1516

1617
export type RelayerStateTransition = 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'destroyed';
1718

19+
export interface WebSocketRelayerOptions {
20+
maxReconnectAttempts?: number;
21+
reconnectDelayMs?: number;
22+
onStateChange?: StateChangeHandler;
23+
}
24+
1825
export class WebSocketRelayer {
1926
private url: string;
2027
private ws: WebSocket | null = null;
2128
private handlers: Map<string, Set<MessageHandler>> = new Map();
29+
private stateChangeHandlers: Set<StateChangeHandler> = new Set();
2230
private isDestroyed = false;
2331
private isLocked = false;
2432
private lockQueue: Array<() => void> = [];
2533
private connectPromise: Promise<void> | null = null;
2634
private reconnectAttempts = 0;
35+
private reconnectEnabled = true;
2736
private maxReconnectAttempts: number;
2837
private reconnectDelayMs: number;
2938
private pendingMessages: WebSocketMessage[] = [];
39+
private stateTransition: RelayerStateTransition = 'disconnected';
3040

31-
constructor(url: string, options?: { maxReconnectAttempts?: number; reconnectDelayMs?: number }) {
41+
constructor(url: string, options?: WebSocketRelayerOptions) {
3242
this.url = url;
3343
this.maxReconnectAttempts = options?.maxReconnectAttempts ?? 5;
3444
this.reconnectDelayMs = options?.reconnectDelayMs ?? 1000;
45+
if (options?.onStateChange) {
46+
this.stateChangeHandlers.add(options.onStateChange);
47+
}
3548
}
3649

3750
get state(): RelayerState {
@@ -62,10 +75,25 @@ export class WebSocketRelayer {
6275
}
6376
}
6477

78+
private emitStateChange(transition: RelayerStateTransition): void {
79+
if (this.stateTransition === transition) return;
80+
81+
this.stateTransition = transition;
82+
const state = this.state;
83+
for (const handler of this.stateChangeHandlers) {
84+
try {
85+
handler(transition, state);
86+
} catch (err) {
87+
console.warn('[WebSocketRelayer] state change handler error:', err);
88+
}
89+
}
90+
}
91+
6592
async connect(): Promise<void> {
6693
if (this.isDestroyed) {
6794
throw new Error('WebSocketRelayer has been destroyed');
6895
}
96+
this.reconnectEnabled = true;
6997
if (this.connectPromise) {
7098
return this.connectPromise;
7199
}
@@ -76,6 +104,7 @@ export class WebSocketRelayer {
76104
return;
77105
}
78106

107+
this.emitStateChange('connecting');
79108
this.connectPromise = this.establishConnection();
80109
await this.connectPromise;
81110
} finally {
@@ -97,6 +126,7 @@ export class WebSocketRelayer {
97126
private async establishConnection(): Promise<void> {
98127
const WebSocketCtor = this.wsCtor();
99128
if (!WebSocketCtor) {
129+
this.emitStateChange('disconnected');
100130
return;
101131
}
102132

@@ -113,9 +143,14 @@ export class WebSocketRelayer {
113143
return;
114144
}
115145

146+
let settled = false;
147+
116148
ws.onopen = () => {
149+
if (settled) return;
150+
settled = true;
117151
this.reconnectAttempts = 0;
118152
this.flushPendingMessages();
153+
this.emitStateChange('connected');
119154
resolve();
120155
};
121156

@@ -125,13 +160,19 @@ export class WebSocketRelayer {
125160

126161
ws.onclose = () => {
127162
this.ws = null;
128-
if (!this.isDestroyed) {
163+
if (!settled) {
164+
settled = true;
165+
reject(new Error(`WebSocket connection closed before opening: ${this.url}`));
166+
}
167+
this.emitStateChange('disconnected');
168+
if (!this.isDestroyed && this.reconnectEnabled) {
129169
this.attemptReconnect();
130170
}
131171
};
132172

133173
ws.onerror = () => {
134-
if (this.ws === null) {
174+
if (!settled) {
175+
settled = true;
135176
reject(new Error(`WebSocket connection failed: ${this.url}`));
136177
}
137178
};
@@ -181,6 +222,7 @@ export class WebSocketRelayer {
181222
private shouldAttemptReconnect(): boolean {
182223
return (
183224
!this.isDestroyed &&
225+
this.reconnectEnabled &&
184226
this.reconnectAttempts < this.maxReconnectAttempts &&
185227
(!this.ws || this.ws.readyState !== 1)
186228
);
@@ -202,6 +244,7 @@ export class WebSocketRelayer {
202244
if (!this.shouldAttemptReconnect()) return;
203245

204246
this.reconnectAttempts++;
247+
this.emitStateChange('reconnecting');
205248
await new Promise((r) => setTimeout(r, this.reconnectDelayMs * this.reconnectAttempts));
206249
if (this.isDestroyed) return;
207250

@@ -230,6 +273,14 @@ export class WebSocketRelayer {
230273
};
231274
}
232275

276+
onStateChange(handler: StateChangeHandler): () => void {
277+
this.stateChangeHandlers.add(handler);
278+
279+
return () => {
280+
this.stateChangeHandlers.delete(handler);
281+
};
282+
}
283+
233284
off(type: string, handler: MessageHandler): void {
234285
const handlers = this.handlers.get(type);
235286
if (handlers) {
@@ -266,7 +317,9 @@ export class WebSocketRelayer {
266317
}
267318

268319
disconnect(): void {
320+
this.reconnectEnabled = false;
269321
this.connectPromise = null;
322+
this.reconnectAttempts = 0;
270323

271324
if (this.ws) {
272325
try {
@@ -276,10 +329,12 @@ export class WebSocketRelayer {
276329
}
277330
this.ws = null;
278331
}
332+
this.emitStateChange('disconnected');
279333
}
280334

281335
destroy(): void {
282336
this.isDestroyed = true;
337+
this.reconnectEnabled = false;
283338
this.connectPromise = null;
284339
this.pendingMessages = [];
285340
this.reconnectAttempts = this.maxReconnectAttempts;
@@ -296,8 +351,10 @@ export class WebSocketRelayer {
296351
this.ws = null;
297352
}
298353

354+
this.emitStateChange('destroyed');
299355
this.handlers.clear();
356+
this.stateChangeHandlers.clear();
300357
this.lockQueue = [];
301358
this.isLocked = false;
302359
}
303-
}
360+
}

‎src/tests/builder-validation-bypass.test.ts‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ describe('ConduitBatcher validation', () => {
6363

6464
it('should reject item with invalid sender (non G-address)', () => {
6565
const result = ConduitBatcher.execute([
66-
{ token: 'CDLZFC3SYJYDZT7K3VZ3B7GJ4G5FCAH5G6O6V7J7K7L7M7N7O7P7Q7R7S', sender: 'not-a-valid-key', recipient: 'GB1', amount: 100 },
66+
{ token: 'CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526', sender: 'not-a-valid-key', recipient: 'GB1', amount: 100 },
6767
]);
6868
expect(result.success).toBe(false);
6969
expect(result.errors).toBeDefined();
@@ -72,7 +72,7 @@ describe('ConduitBatcher validation', () => {
7272

7373
it('should reject item with invalid recipient (non G-address)', () => {
7474
const result = ConduitBatcher.execute([
75-
{ token: 'CDLZFC3SYJYDZT7K3VZ3B7GJ4G5FCAH5G6O6V7J7K7L7M7N7O7P7Q7R7S', sender: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', recipient: 'bad-recipient', amount: 100 },
75+
{ token: 'CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526', sender: 'GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H', recipient: 'bad-recipient', amount: 100 },
7676
]);
7777
expect(result.success).toBe(false);
7878
expect(result.errors).toBeDefined();
@@ -81,7 +81,7 @@ describe('ConduitBatcher validation', () => {
8181

8282
it('should reject item with zero amount', () => {
8383
const result = ConduitBatcher.execute([
84-
{ token: 'CDLZFC3SYJYDZT7K3VZ3B7GJ4G5FCAH5G6O6V7J7K7L7M7N7O7P7Q7R7S', sender: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', recipient: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', amount: 0 },
84+
{ token: 'CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526', sender: 'GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H', recipient: 'GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H', amount: 0 },
8585
]);
8686
expect(result.success).toBe(false);
8787
expect(result.errors).toBeDefined();
@@ -90,7 +90,7 @@ describe('ConduitBatcher validation', () => {
9090

9191
it('should reject item with negative amount', () => {
9292
const result = ConduitBatcher.execute([
93-
{ token: 'CDLZFC3SYJYDZT7K3VZ3B7GJ4G5FCAH5G6O6V7J7K7L7M7N7O7P7Q7R7S', sender: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', recipient: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', amount: -50 },
93+
{ token: 'CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526', sender: 'GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H', recipient: 'GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H', amount: -50 },
9494
]);
9595
expect(result.success).toBe(false);
9696
expect(result.errors).toBeDefined();
@@ -101,9 +101,9 @@ describe('ConduitBatcher validation', () => {
101101
const validPayload = [{
102102
method: 'create',
103103
params: { amount: 100 },
104-
token: 'CDLZFC3SYJYDZT7K3VZ3B7GJ4G5FCAH5G6O6V7J7K7L7M7N7O7P7Q7R7S',
105-
sender: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN',
106-
recipient: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN',
104+
token: 'CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526',
105+
sender: 'GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H',
106+
recipient: 'GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H',
107107
}];
108108
const result = ConduitBatcher.execute(validPayload);
109109
expect(result.success).toBe(true);
@@ -135,7 +135,7 @@ describe('ConduitBatcher validation', () => {
135135
describe('StreamBuilder address validation', () => {
136136
it('should accept valid token (C-address)', () => {
137137
const builder = new StreamBuilder();
138-
expect(() => builder.token('CDLZFC3SYJYDZT7K3VZ3B7GJ4G5FCAH5G6O6V7J7K7L7M7N7O7P7Q7R7S')).not.toThrow();
138+
expect(() => builder.token('CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526')).not.toThrow();
139139
});
140140

141141
it('should reject invalid token address', () => {
@@ -150,7 +150,7 @@ describe('StreamBuilder address validation', () => {
150150

151151
it('should accept valid sender (G-address)', () => {
152152
const builder = new StreamBuilder();
153-
expect(() => builder.sender('GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN')).not.toThrow();
153+
expect(() => builder.sender('GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H')).not.toThrow();
154154
});
155155

156156
it('should reject invalid sender address', () => {
@@ -160,7 +160,7 @@ describe('StreamBuilder address validation', () => {
160160

161161
it('should accept valid recipient (G-address)', () => {
162162
const builder = new StreamBuilder();
163-
expect(() => builder.recipient('GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN')).not.toThrow();
163+
expect(() => builder.recipient('GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H')).not.toThrow();
164164
});
165165

166166
it('should reject invalid recipient address', () => {

‎src/tests/error-mapper-listener-cleanup.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ describe('ErrorMapper — listener cleanup (#81)', () => {
2727
const created = createMockWs();
2828
mockWs = created.mock;
2929
getOnmessage = created.onmessage;
30-
(global as any).WebSocket = vi.fn(() => mockWs) as any;
30+
(global as any).WebSocket = vi.fn(function () { return mockWs; }) as any;
3131
relayer = new WebSocketRelayer('ws://localhost:8080');
3232
});
3333

0 commit comments

Comments
 (0)