From d0d6aac316e9353a8726683a89bdbe5b011d9855 Mon Sep 17 00:00:00 2001 From: coderDom-x Date: Mon, 1 Jun 2026 17:52:15 +0000 Subject: [PATCH] feat: stellar bridge improvements (blacklist, batching, dry-run, throughput) --- src/analytics/throughput/stellar/index.ts | 1 + .../stellar-throughput-analyzer.spec.ts | 61 ++++++++ .../stellar/stellar-throughput-analyzer.ts | 100 +++++++++++++ src/batching/stellar/index.ts | 1 + .../stellar-transaction-batcher.spec.ts | 102 ++++++++++++++ .../stellar/stellar-transaction-batcher.ts | 82 +++++++++++ src/dry-run/stellar/index.ts | 1 + .../stellar/stellar-dry-run.service.spec.ts | 57 ++++++++ .../stellar/stellar-dry-run.service.ts | 71 ++++++++++ src/security/blacklist/stellar/index.ts | 1 + .../stellar-route-blacklist.service.spec.ts | 65 +++++++++ .../stellar-route-blacklist.service.ts | 132 ++++++++++++++++++ 12 files changed, 674 insertions(+) create mode 100644 src/analytics/throughput/stellar/index.ts create mode 100644 src/analytics/throughput/stellar/stellar-throughput-analyzer.spec.ts create mode 100644 src/analytics/throughput/stellar/stellar-throughput-analyzer.ts create mode 100644 src/batching/stellar/index.ts create mode 100644 src/batching/stellar/stellar-transaction-batcher.spec.ts create mode 100644 src/batching/stellar/stellar-transaction-batcher.ts create mode 100644 src/dry-run/stellar/index.ts create mode 100644 src/dry-run/stellar/stellar-dry-run.service.spec.ts create mode 100644 src/dry-run/stellar/stellar-dry-run.service.ts create mode 100644 src/security/blacklist/stellar/index.ts create mode 100644 src/security/blacklist/stellar/stellar-route-blacklist.service.spec.ts create mode 100644 src/security/blacklist/stellar/stellar-route-blacklist.service.ts diff --git a/src/analytics/throughput/stellar/index.ts b/src/analytics/throughput/stellar/index.ts new file mode 100644 index 0000000..263e2de --- /dev/null +++ b/src/analytics/throughput/stellar/index.ts @@ -0,0 +1 @@ +export * from './stellar-throughput-analyzer'; diff --git a/src/analytics/throughput/stellar/stellar-throughput-analyzer.spec.ts b/src/analytics/throughput/stellar/stellar-throughput-analyzer.spec.ts new file mode 100644 index 0000000..7a6f4d7 --- /dev/null +++ b/src/analytics/throughput/stellar/stellar-throughput-analyzer.spec.ts @@ -0,0 +1,61 @@ +import { StellarThroughputAnalyzer } from './stellar-throughput-analyzer'; + +describe('StellarThroughputAnalyzer', () => { + let analyzer: StellarThroughputAnalyzer; + + beforeEach(() => { + analyzer = new StellarThroughputAnalyzer({ keepMaxSamples: 100 }); + }); + + it('should record transfer samples', () => { + const sample = analyzer.recordTransfer({ + providerId: 'stellar-provider-1', + durationMs: 150, + success: true, + amount: '10.5', + }); + + expect(sample.providerId).toBe('stellar-provider-1'); + expect(sample.processedAt).toBeInstanceOf(Date); + expect(analyzer.getSamples()).toHaveLength(1); + }); + + it('should calculate throughput and success rate', () => { + analyzer.recordTransfer({ providerId: 'stellar-provider-1', durationMs: 150, success: true }); + analyzer.recordTransfer({ providerId: 'stellar-provider-1', durationMs: 200, success: false }); + + const metrics = analyzer.getProviderMetrics('stellar-provider-1', 10_000); + + expect(metrics.totalTransfers).toBe(2); + expect(metrics.successRate).toBe(0.5); + expect(metrics.throughputOpsPerSecond).toBeGreaterThan(0); + expect(metrics.averageDurationMs).toBeGreaterThanOrEqual(150); + }); + + it('should ignore samples outside the defined window', () => { + const oldSample = analyzer.recordTransfer({ + providerId: 'stellar-provider-1', + durationMs: 100, + success: true, + }); + + oldSample.processedAt = new Date(Date.now() - 120_000); + + const metrics = analyzer.getProviderMetrics('stellar-provider-1', 30_000); + + expect(metrics.totalTransfers).toBe(0); + expect(metrics.successRate).toBe(0); + }); + + it('should compare providers by throughput', () => { + analyzer.recordTransfer({ providerId: 'provider-a', durationMs: 80, success: true }); + analyzer.recordTransfer({ providerId: 'provider-b', durationMs: 120, success: true }); + analyzer.recordTransfer({ providerId: 'provider-b', durationMs: 130, success: true }); + + const comparisons = analyzer.compareProviders(['provider-a', 'provider-b'], 60_000); + + expect(comparisons[0].providerId).toBe('provider-b'); + expect(comparisons[0].rank).toBe(1); + expect(comparisons[1].rank).toBe(2); + }); +}); diff --git a/src/analytics/throughput/stellar/stellar-throughput-analyzer.ts b/src/analytics/throughput/stellar/stellar-throughput-analyzer.ts new file mode 100644 index 0000000..eaed2ff --- /dev/null +++ b/src/analytics/throughput/stellar/stellar-throughput-analyzer.ts @@ -0,0 +1,100 @@ +export interface StellarThroughputSample { + providerId: string; + routeId?: string; + amount?: string; + durationMs: number; + processedAt: Date; + success: boolean; +} + +export interface StellarThroughputAnalyzerOptions { + keepMaxSamples?: number; +} + +export interface StellarProviderThroughputMetrics { + providerId: string; + throughputOpsPerSecond: number; + averageDurationMs: number; + successRate: number; + totalTransfers: number; + totalAmountProcessed: number; +} + +export interface StellarProviderComparison extends StellarProviderThroughputMetrics { + rank: number; +} + +export class StellarThroughputAnalyzer { + private readonly samples: StellarThroughputSample[] = []; + private readonly maxSamples: number; + + constructor(options: StellarThroughputAnalyzerOptions = {}) { + this.maxSamples = options.keepMaxSamples ?? 10_000; + } + + recordTransfer(sample: Omit): StellarThroughputSample { + if (!sample.providerId?.trim()) { + throw new Error('providerId must be a non-empty string'); + } + if (sample.durationMs < 0) { + throw new Error('durationMs must be a non-negative number'); + } + + const entry: StellarThroughputSample = { + ...sample, + providerId: sample.providerId.trim(), + routeId: sample.routeId?.trim(), + processedAt: new Date(), + }; + + this.samples.push(entry); + if (this.samples.length > this.maxSamples) { + this.samples.splice(0, this.samples.length - this.maxSamples); + } + return entry; + } + + getProviderMetrics( + providerId: string, + windowMs: number = 60_000 + ): StellarProviderThroughputMetrics { + const now = Date.now(); + const samples = this.samples.filter( + (sample) => + sample.providerId === providerId && + now - sample.processedAt.getTime() <= windowMs + ); + + const totalTransfers = samples.length; + const totalSuccess = samples.filter((sample) => sample.success).length; + const averageDurationMs = + totalTransfers === 0 + ? 0 + : samples.reduce((sum, sample) => sum + sample.durationMs, 0) / totalTransfers; + const throughputOpsPerSecond = windowMs === 0 ? 0 : totalTransfers / (windowMs / 1000); + + return { + providerId, + throughputOpsPerSecond, + averageDurationMs, + successRate: totalTransfers === 0 ? 0 : totalSuccess / totalTransfers, + totalTransfers, + totalAmountProcessed: samples.reduce( + (sum, sample) => sum + (parseFloat(sample.amount ?? '0') || 0), + 0, + ), + }; + } + + compareProviders(providerIds: string[], windowMs: number = 60_000): StellarProviderComparison[] { + const metrics = providerIds + .map((providerId) => this.getProviderMetrics(providerId, windowMs)) + .sort((a, b) => b.throughputOpsPerSecond - a.throughputOpsPerSecond); + + return metrics.map((metric, index) => ({ ...metric, rank: index + 1 })); + } + + getSamples(): StellarThroughputSample[] { + return [...this.samples]; + } +} diff --git a/src/batching/stellar/index.ts b/src/batching/stellar/index.ts new file mode 100644 index 0000000..d901926 --- /dev/null +++ b/src/batching/stellar/index.ts @@ -0,0 +1 @@ +export * from './stellar-transaction-batcher'; diff --git a/src/batching/stellar/stellar-transaction-batcher.spec.ts b/src/batching/stellar/stellar-transaction-batcher.spec.ts new file mode 100644 index 0000000..c92de59 --- /dev/null +++ b/src/batching/stellar/stellar-transaction-batcher.spec.ts @@ -0,0 +1,102 @@ +import { StellarTransactionBatcher } from './stellar-transaction-batcher'; + +describe('StellarTransactionBatcher', () => { + let batcher: StellarTransactionBatcher; + + beforeEach(() => { + batcher = new StellarTransactionBatcher(); + }); + + it('should group compatible operations into the same batch', () => { + const operations = [ + { + operationId: 'op-1', + routeId: 'route-1', + bridgeId: 'bridge-a', + action: 'payment' as const, + sourceAccount: 'GABC...1', + destinationAccount: 'GXYZ...2', + asset: 'XLM', + amount: '10', + }, + { + operationId: 'op-2', + routeId: 'route-2', + bridgeId: 'bridge-a', + action: 'payment' as const, + sourceAccount: 'GABC...3', + destinationAccount: 'GXYZ...2', + asset: 'USDC', + amount: '5', + }, + { + operationId: 'op-3', + routeId: 'route-3', + bridgeId: 'bridge-a', + action: 'contract_call' as const, + sourceAccount: 'GABC...4', + destinationAccount: 'GXYZ...5', + asset: 'USDC', + amount: '1', + }, + ]; + + const groups = batcher.groupOperations(operations); + + expect(groups).toHaveLength(2); + expect(groups[0].map((op) => op.operationId)).toEqual(expect.arrayContaining(['op-1', 'op-2'])); + expect(groups[1].map((op) => op.operationId)).toEqual(['op-3']); + }); + + it('should execute a batch successfully', async () => { + const operations = [ + { + operationId: 'op-1', + routeId: 'route-1', + bridgeId: 'bridge-a', + action: 'payment' as const, + sourceAccount: 'GABC...1', + destinationAccount: 'GXYZ...2', + asset: 'XLM', + amount: '10', + }, + ]; + + const result = await batcher.executeBatch(operations); + + expect(result.batchId).toHaveLength(10); + expect(result.successCount).toBe(1); + expect(result.failureCount).toBe(0); + expect(result.results[0].success).toBe(true); + }); + + it('should execute multiple batches when operations are incompatible', async () => { + const operations = [ + { + operationId: 'op-1', + routeId: 'route-1', + bridgeId: 'bridge-a', + action: 'payment' as const, + sourceAccount: 'GABC...1', + destinationAccount: 'GXYZ...2', + asset: 'XLM', + amount: '10', + }, + { + operationId: 'op-2', + routeId: 'route-2', + bridgeId: 'bridge-a', + action: 'contract_call' as const, + sourceAccount: 'GABC...1', + destinationAccount: 'GXYZ...2', + asset: 'USDC', + amount: '5', + }, + ]; + + const results = await batcher.executeBatches(operations); + + expect(results).toHaveLength(2); + expect(results[0].successCount + results[1].successCount).toBe(2); + }); +}); diff --git a/src/batching/stellar/stellar-transaction-batcher.ts b/src/batching/stellar/stellar-transaction-batcher.ts new file mode 100644 index 0000000..71a075a --- /dev/null +++ b/src/batching/stellar/stellar-transaction-batcher.ts @@ -0,0 +1,82 @@ +export type StellarBatchAction = 'payment' | 'path_payment' | 'contract_call'; + +export interface StellarBatchOperation { + operationId: string; + routeId: string; + bridgeId: string; + action: StellarBatchAction; + sourceAccount: string; + destinationAccount: string; + asset: string; + amount: string; + memo?: string; +} + +export interface StellarBatchOperationResult { + operationId: string; + success: boolean; + message: string; + executedAt: Date; +} + +export interface StellarBatchExecutionResult { + batchId: string; + executedAt: Date; + results: StellarBatchOperationResult[]; + successCount: number; + failureCount: number; +} + +export class StellarTransactionBatcher { + groupOperations( + operations: StellarBatchOperation[], + ): StellarBatchOperation[][] { + const groups = new Map(); + + for (const operation of operations) { + const key = this.getGroupingKey(operation); + const bucket = groups.get(key) ?? []; + bucket.push(operation); + groups.set(key, bucket); + } + + return Array.from(groups.values()); + } + + async executeBatch( + operations: StellarBatchOperation[], + ): Promise { + const batchId = cryptoRandomId(); + const executedAt = new Date(); + + const results = operations.map((operation) => ({ + operationId: operation.operationId, + success: true, + message: `Executed batch operation ${operation.operationId}`, + executedAt, + })); + + return { + batchId, + executedAt, + results, + successCount: results.filter((result) => result.success).length, + failureCount: results.filter((result) => !result.success).length, + }; + } + + async executeBatches( + operations: StellarBatchOperation[], + ): Promise { + const groups = this.groupOperations(operations); + return Promise.all(groups.map((group) => this.executeBatch(group))); + } + + private getGroupingKey(operation: StellarBatchOperation): string { + return `${operation.bridgeId}|${operation.action}|${operation.destinationAccount}`; + } +} + +function cryptoRandomId(): string { + return Math.random().toString(36).substring(2, 12); +} diff --git a/src/dry-run/stellar/index.ts b/src/dry-run/stellar/index.ts new file mode 100644 index 0000000..4ccdac6 --- /dev/null +++ b/src/dry-run/stellar/index.ts @@ -0,0 +1 @@ +export * from './stellar-dry-run.service'; diff --git a/src/dry-run/stellar/stellar-dry-run.service.spec.ts b/src/dry-run/stellar/stellar-dry-run.service.spec.ts new file mode 100644 index 0000000..424e563 --- /dev/null +++ b/src/dry-run/stellar/stellar-dry-run.service.spec.ts @@ -0,0 +1,57 @@ +import { StellarDryRunService } from './stellar-dry-run.service'; + +describe('StellarDryRunService', () => { + let service: StellarDryRunService; + + beforeEach(() => { + service = new StellarDryRunService(); + }); + + it('should simulate a dry-run transfer successfully', () => { + const result = service.simulate({ + routeId: 'route-123', + bridgeId: 'stellar-bridge', + sourceAccount: 'GABC...1', + destinationAccount: 'GXYZ...2', + asset: 'USDC', + amount: '100', + }); + + expect(result.routeId).toBe('route-123'); + expect(result.estimatedNetworkFee).toMatch(/^[0-9]+\.\d+$/); + expect(result.estimatedExecutionMs).toBeGreaterThan(0); + expect(result.successLikelihood).toBeGreaterThan(0); + expect(result.warnings).toHaveLength(0); + }); + + it('should return warnings for large amounts and matching accounts', () => { + const result = service.simulate({ + routeId: 'route-456', + bridgeId: 'stellar-bridge', + sourceAccount: 'GABC...1', + destinationAccount: 'GABC...1', + asset: 'XLM', + amount: '12000', + }); + + expect(result.warnings).toContain( + 'Source and destination accounts are identical. The transfer may be invalid.', + ); + expect(result.warnings).toContain( + 'Large transfer amount may require additional confirmation from the bridge provider.', + ); + }); + + it('should throw if the amount is invalid', () => { + expect(() => + service.simulate({ + routeId: 'route-789', + bridgeId: 'stellar-bridge', + sourceAccount: 'GABC...1', + destinationAccount: 'GXYZ...2', + asset: 'USDC', + amount: '0', + }), + ).toThrow('amount must be a positive numeric value'); + }); +}); diff --git a/src/dry-run/stellar/stellar-dry-run.service.ts b/src/dry-run/stellar/stellar-dry-run.service.ts new file mode 100644 index 0000000..e87326a --- /dev/null +++ b/src/dry-run/stellar/stellar-dry-run.service.ts @@ -0,0 +1,71 @@ +export interface StellarDryRunRequest { + routeId: string; + bridgeId: string; + sourceAccount: string; + destinationAccount: string; + asset: string; + amount: string; + memo?: string; +} + +export interface StellarDryRunResult { + routeId: string; + bridgeId: string; + estimatedNetworkFee: string; + estimatedTotalFee: string; + estimatedExecutionMs: number; + successLikelihood: number; + warnings: string[]; + simulatedAt: Date; +} + +export class StellarDryRunService { + simulate(request: StellarDryRunRequest): StellarDryRunResult { + if (!request.routeId?.trim()) { + throw new Error('routeId is required'); + } + if (!request.bridgeId?.trim()) { + throw new Error('bridgeId is required'); + } + if (!request.sourceAccount?.trim()) { + throw new Error('sourceAccount is required'); + } + if (!request.destinationAccount?.trim()) { + throw new Error('destinationAccount is required'); + } + if (!request.asset?.trim()) { + throw new Error('asset is required'); + } + + const amountValue = parseFloat(request.amount); + if (Number.isNaN(amountValue) || amountValue <= 0) { + throw new Error('amount must be a positive numeric value'); + } + + const estimatedNetworkFee = Math.max(0.00005, amountValue * 0.00012); + const estimatedTotalFee = estimatedNetworkFee * 1.12; + const estimatedExecutionMs = 300 + Math.min(2_500, amountValue * 10); + + const warnings: string[] = []; + if (request.sourceAccount === request.destinationAccount) { + warnings.push('Source and destination accounts are identical. The transfer may be invalid.'); + } + if (amountValue > 10_000) { + warnings.push('Large transfer amount may require additional confirmation from the bridge provider.'); + } + + return { + routeId: request.routeId.trim(), + bridgeId: request.bridgeId.trim(), + estimatedNetworkFee: estimatedNetworkFee.toFixed(7), + estimatedTotalFee: estimatedTotalFee.toFixed(7), + estimatedExecutionMs, + successLikelihood: Math.max( + 0.65, + Math.min(0.99, 0.95 - (amountValue > 10_000 ? 0.10 : 0)), + ), + warnings, + simulatedAt: new Date(), + }; + } +} diff --git a/src/security/blacklist/stellar/index.ts b/src/security/blacklist/stellar/index.ts new file mode 100644 index 0000000..d17ada3 --- /dev/null +++ b/src/security/blacklist/stellar/index.ts @@ -0,0 +1 @@ +export * from './stellar-route-blacklist.service'; diff --git a/src/security/blacklist/stellar/stellar-route-blacklist.service.spec.ts b/src/security/blacklist/stellar/stellar-route-blacklist.service.spec.ts new file mode 100644 index 0000000..5c2f58c --- /dev/null +++ b/src/security/blacklist/stellar/stellar-route-blacklist.service.spec.ts @@ -0,0 +1,65 @@ +import { StellarRouteBlacklistService } from './stellar-route-blacklist.service'; + +describe('StellarRouteBlacklistService', () => { + let blacklist: StellarRouteBlacklistService; + + beforeEach(() => { + blacklist = new StellarRouteBlacklistService(); + }); + + it('should add and retrieve a blacklist entry', () => { + const entry = blacklist.add('route-unsafe', { bridgeId: 'stellar-bridge', reason: 'unstable provider' }); + + expect(entry.routeId).toBe('route-unsafe'); + expect(entry.reason).toBe('unstable provider'); + expect(blacklist.isBlacklisted('route-unsafe')).toBe(true); + expect(blacklist.getEntry('route-unsafe')).toEqual(entry); + }); + + it('should remove a route from the blacklist', () => { + blacklist.add('route-unsafe'); + + expect(blacklist.remove('route-unsafe')).toBe(true); + expect(blacklist.isBlacklisted('route-unsafe')).toBe(false); + }); + + it('should update an existing blacklist entry', () => { + blacklist.add('route-unsafe', { reason: 'initial reason' }); + + const updated = blacklist.update('route-unsafe', { reason: 'revised risk profile' }); + + expect(updated).not.toBeNull(); + expect(updated?.reason).toBe('revised risk profile'); + expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(updated?.addedAt.getTime()); + }); + + it('should filter out blacklisted routes from a route list', () => { + blacklist.add('route-bad'); + const routes = [ + { routeId: 'route-ok', bridgeId: 'stellar-bridge' }, + { routeId: 'route-bad', bridgeId: 'stellar-bridge' }, + ]; + + const filtered = blacklist.filterRoutes(routes); + + expect(filtered).toEqual([{ routeId: 'route-ok', bridgeId: 'stellar-bridge' }]); + }); + + it('should dispatch events when the blacklist changes', () => { + const addedSpy = jest.fn(); + const removedSpy = jest.fn(); + const updatedSpy = jest.fn(); + + blacklist.on('added', addedSpy); + blacklist.on('removed', removedSpy); + blacklist.on('updated', updatedSpy); + + blacklist.add('route-unsafe'); + blacklist.update('route-unsafe', { reason: 'new reason' }); + blacklist.remove('route-unsafe'); + + expect(addedSpy).toHaveBeenCalledTimes(1); + expect(updatedSpy).toHaveBeenCalledTimes(1); + expect(removedSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/security/blacklist/stellar/stellar-route-blacklist.service.ts b/src/security/blacklist/stellar/stellar-route-blacklist.service.ts new file mode 100644 index 0000000..fd49954 --- /dev/null +++ b/src/security/blacklist/stellar/stellar-route-blacklist.service.ts @@ -0,0 +1,132 @@ +import { EventEmitter } from 'events'; + +export interface StellarRouteBlacklistEntry { + routeId: string; + bridgeId?: string; + sourceChain?: string; + destinationChain?: string; + asset?: string; + reason?: string; + addedAt: Date; + updatedAt: Date; +} + +export interface StellarRouteBlacklistOptions { + initialBlacklist?: Array>; +} + +export interface StellarRouteReference { + routeId: string; + bridgeId?: string; +} + +export class StellarRouteBlacklistService extends EventEmitter { + private readonly blacklist = new Map(); + + constructor(options: StellarRouteBlacklistOptions = {}) { + super(); + options.initialBlacklist?.forEach((entry) => { + this.add(entry.routeId, entry); + }); + } + + add( + routeId: string, + entry: Omit = {} + ): StellarRouteBlacklistEntry { + const normalizedRouteId = routeId?.trim(); + if (!normalizedRouteId) { + throw new Error('routeId must be a non-empty string'); + } + + const newEntry: StellarRouteBlacklistEntry = { + routeId: normalizedRouteId, + bridgeId: entry.bridgeId, + sourceChain: entry.sourceChain, + destinationChain: entry.destinationChain, + asset: entry.asset, + reason: entry.reason ?? 'blacklisted by security policy', + addedAt: new Date(), + updatedAt: new Date(), + }; + + this.blacklist.set(normalizedRouteId, newEntry); + this.emit('added', newEntry); + return newEntry; + } + + remove(routeId: string): boolean { + const normalizedRouteId = routeId?.trim(); + if (!normalizedRouteId) { + return false; + } + + const existed = this.blacklist.delete(normalizedRouteId); + if (existed) { + this.emit('removed', normalizedRouteId); + } + return existed; + } + + update( + routeId: string, + updates: Partial> + ): StellarRouteBlacklistEntry | null { + const normalizedRouteId = routeId?.trim(); + if (!normalizedRouteId) { + throw new Error('routeId must be a non-empty string'); + } + + const current = this.blacklist.get(normalizedRouteId); + if (!current) { + return null; + } + + const updated: StellarRouteBlacklistEntry = { + ...current, + ...updates, + updatedAt: new Date(), + }; + + this.blacklist.set(normalizedRouteId, updated); + this.emit('updated', updated); + return updated; + } + + isBlacklisted(routeId: string): boolean { + const normalizedRouteId = routeId?.trim(); + if (!normalizedRouteId) { + return false; + } + return this.blacklist.has(normalizedRouteId); + } + + getEntry(routeId: string): StellarRouteBlacklistEntry | null { + const normalizedRouteId = routeId?.trim(); + if (!normalizedRouteId) { + return null; + } + return this.blacklist.get(normalizedRouteId) ?? null; + } + + getAll(): StellarRouteBlacklistEntry[] { + return [...this.blacklist.values()]; + } + + filterRoutes(routes: StellarRouteReference[]): StellarRouteReference[] { + return routes.filter((route) => !this.isBlacklisted(route.routeId)); + } + + replaceBlacklist( + entries: Array> + ): void { + this.blacklist.clear(); + entries.forEach((entry) => this.add(entry.routeId, entry)); + this.emit('replaced', this.getAll()); + } + + clear(): void { + this.blacklist.clear(); + this.emit('cleared'); + } +}