Skip to content
Open
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
196 changes: 196 additions & 0 deletions app/components/SimulationResultSheet/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
import { SimulateTransactionDto, SimulationResponseDto, SimulationErrorCode } from '../../../shared/types/simulation';

interface Props {
simulationResult: SimulationResponseDto | null;
onConfirm: () => void;
onCancel: () => void;
isVisible: boolean;
}

export const SimulationResultSheet: React.FC<Props> = ({ simulationResult, onConfirm, onCancel, isVisible }) => {
if (!isVisible) return null;

return (
<View style={styles.container}>
<View style={styles.sheet}>
<Text style={styles.title}>Transaction Simulation</Text>

<ScrollView style={styles.content}>
{!simulationResult ? (
<Text style={styles.warningText}>Simulating...</Text>
) : !simulationResult.success ? (
<View style={styles.errorContainer}>
<Text style={styles.errorTitle}>Simulation Failed</Text>
{simulationResult.predictedErrors?.map((err, i) => (
<Text key={i} style={styles.errorText}>• {err}</Text>
))}
{simulationResult.predictedErrors?.includes(SimulationErrorCode.NETWORK_ERROR) && (
<Text style={styles.warningText}>
Simulation unavailable. Transaction can still be submitted, but may fail on-chain.
</Text>
)}
</View>
) : (
<View style={styles.successContainer}>
<Text style={styles.successTitle}>Simulation Successful</Text>

<View style={styles.row}>
<Text style={styles.label}>Expected Result:</Text>
<Text style={styles.value}>{simulationResult.expectedResult?.status}</Text>
</View>

{simulationResult.gasEstimate && (
<>
<View style={styles.row}>
<Text style={styles.label}>Estimated Fee:</Text>
<Text style={styles.value}>{simulationResult.gasEstimate.estimatedFee} stroops</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>CPU Instructions:</Text>
<Text style={styles.value}>{simulationResult.gasEstimate.cpuInsns}</Text>
</View>
</>
)}

{simulationResult.requiredAuth && simulationResult.requiredAuth.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Required Authorizations:</Text>
{simulationResult.requiredAuth.map((auth, i) => (
<Text key={i} style={styles.value}>• {auth.address} ({auth.role})</Text>
))}
</View>
)}

{simulationResult.stateDiff && simulationResult.stateDiff.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>State Changes:</Text>
{simulationResult.stateDiff.map((diff, i) => (
<Text key={i} style={styles.value}>• Contract {diff.contractId?.substring(0,8)}...</Text>
))}
</View>
)}
</View>
)}
</ScrollView>

<View style={styles.actions}>
<TouchableOpacity style={styles.cancelButton} onPress={onCancel}>
<Text style={styles.cancelText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.confirmButton, simulationResult && !simulationResult.success && !simulationResult.predictedErrors?.includes(SimulationErrorCode.NETWORK_ERROR) ? styles.disabledButton : null]}
onPress={onConfirm}
disabled={simulationResult ? (!simulationResult.success && !simulationResult.predictedErrors?.includes(SimulationErrorCode.NETWORK_ERROR)) : true}
>
<Text style={styles.confirmText}>Confirm & Sign</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
};

const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: 'white',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 20,
maxHeight: '80%',
},
title: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 15,
},
content: {
marginBottom: 20,
},
errorContainer: {
padding: 15,
backgroundColor: '#ffebee',
borderRadius: 8,
},
errorTitle: {
color: '#d32f2f',
fontWeight: 'bold',
marginBottom: 10,
},
errorText: {
color: '#d32f2f',
marginBottom: 5,
},
warningText: {
color: '#ed6c02',
marginTop: 10,
fontStyle: 'italic',
},
successContainer: {
padding: 15,
backgroundColor: '#e8f5e9',
borderRadius: 8,
},
successTitle: {
color: '#2e7d32',
fontWeight: 'bold',
marginBottom: 15,
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 10,
},
label: {
fontWeight: '600',
color: '#555',
},
value: {
color: '#333',
},
section: {
marginTop: 15,
},
sectionTitle: {
fontWeight: '600',
marginBottom: 5,
color: '#555',
},
actions: {
flexDirection: 'row',
justifyContent: 'space-between',
},
cancelButton: {
flex: 1,
padding: 15,
alignItems: 'center',
marginRight: 10,
borderRadius: 8,
backgroundColor: '#f5f5f5',
},
confirmButton: {
flex: 1,
padding: 15,
alignItems: 'center',
marginLeft: 10,
borderRadius: 8,
backgroundColor: '#1976d2',
},
disabledButton: {
backgroundColor: '#9e9e9e',
},
cancelText: {
color: '#333',
fontWeight: 'bold',
},
confirmText: {
color: 'white',
fontWeight: 'bold',
},
});
99 changes: 99 additions & 0 deletions app/services/TransactionSimulationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { SimulateTransactionDto, SimulationResponseDto, SimulationErrorCode } from '../../shared/types/simulation';

const SIMULATION_API_URL = 'http://localhost:3000/transactions/simulate'; // Adjust dynamically based on env if needed
const MAX_SIMULATION_AGE_SECONDS = 60;
const CACHE_KEY_PREFIX = '@simulation_';

export class TransactionSimulationService {
async simulateTransaction(dto: SimulateTransactionDto): Promise<SimulationResponseDto> {
try {
const response = await fetch(SIMULATION_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(dto),
});

if (!response.ok) {
throw new Error('Simulation service unavailable');
}

const result: SimulationResponseDto = await response.json();

// Cache the result
await this.cacheResult(dto.transactionXdr, result);

return result;
} catch (error) {
// Offline mode fallback / network error
console.warn('Simulation unavailable. Transaction can still be submitted, but may fail on-chain.', error);

return {
success: false,
predictedErrors: [SimulationErrorCode.NETWORK_ERROR],
simulationTimestamp: new Date().toISOString(),
};
}
}

private async cacheResult(xdr: string, result: SimulationResponseDto): Promise<void> {
const key = `${CACHE_KEY_PREFIX}${this.hashXdr(xdr)}`;
await AsyncStorage.setItem(key, JSON.stringify({
timestamp: Date.now(),
result
}));
}

async getCachedSimulation(xdr: string): Promise<SimulationResponseDto | null> {
const key = `${CACHE_KEY_PREFIX}${this.hashXdr(xdr)}`;
const cachedStr = await AsyncStorage.getItem(key);

if (!cachedStr) return null;

const cached = JSON.parse(cachedStr);
const ageSeconds = (Date.now() - cached.timestamp) / 1000;

if (ageSeconds > MAX_SIMULATION_AGE_SECONDS) {
await AsyncStorage.removeItem(key);
return null;
}

return cached.result;
}

private hashXdr(xdr: string): string {
// Simple hash function for demo purposes
let hash = 0;
for (let i = 0; i < xdr.length; i++) {
const char = xdr.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16);
}


/**
* Helps handle the state drift edge case.
* If a transaction submission fails AFTER a successful simulation,
*/
handleSubmissionError(error: any): never {
const errorMsg = error?.message?.toLowerCase() || '';
const isStateDrift =
errorMsg.includes('sequence') ||
errorMsg.includes('bad_seq') ||
errorMsg.includes('stale') ||
errorMsg.includes('tx_bad_seq') ||
errorMsg.includes('state changed');

if (isStateDrift) {
throw new Error('Transaction was valid during simulation, but network state changed before submission. Please re-simulate and try again.');
}

throw error;
}
}

export const transactionSimulationService = new TransactionSimulationService();
4 changes: 4 additions & 0 deletions backend/services/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,7 @@ container.bind('IPredictionService', () => new PredictionService());
container.bind('IRecommendationService', () => new RecommendationService());
container.bind('IRetentionService', () => new RetentionService());
container.register('IOracleMonitorService', oracleMonitorService);
// ── Simulation ────────────────────────────────────────────────────────────────
import { SimulationService } from '../simulation/simulation.service';
import { sorobanSimulationClient } from '../simulation/connectors/soroban-simulation.client';
container.bind('ISimulationService', () => new SimulationService(sorobanSimulationClient));
3 changes: 3 additions & 0 deletions backend/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,6 @@ export type {

// ── DI Container ──────────────────────────────────────────────────────────────
export { container, Container } from './container';

// ── Simulation Module ────────────────────────────────────────────────────────
export * from '../simulation';
78 changes: 78 additions & 0 deletions backend/simulation/__tests__/simulation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { SimulationService } from '../simulation.service';
import { SorobanSimulationClient } from '../connectors/soroban-simulation.client';
import { SimulateTransactionDto } from '../../../shared/types/simulation';
import { SimulationErrorCode } from '../../../shared/types/simulation';

jest.mock('../connectors/soroban-simulation.client');

describe('SimulationService', () => {
let service: SimulationService;
let clientMock: jest.Mocked<SorobanSimulationClient>;

beforeEach(() => {
clientMock = new SorobanSimulationClient() as jest.Mocked<SorobanSimulationClient>;
service = new SimulationService(clientMock);
});

it('should return successful simulation result', async () => {
const mockResponse = {
result: {
retval: {
toXDR: () => 'mock_xdr_base64',
},
},
transactionData: {
build: () => ({
fee: () => '100',
}),
},
minResourceFee: '100',
};

clientMock.simulateTransaction.mockResolvedValue(mockResponse);
// Overriding isSimulationSuccess for testing
const rpcApiMock = {
isSimulationSuccess: jest.fn().mockReturnValue(true),
isSimulationError: jest.fn().mockReturnValue(false),
};

// We mock global rpc to avoid complex stellar-sdk dependency
(global as any).rpc = { Api: rpcApiMock };

const dto: SimulateTransactionDto = {
network: 'testnet',
transactionXdr: 'mock_tx_xdr',
};

const result = await service.simulateTransaction(dto);

expect(result.success).toBe(true);
expect(result.expectedResult?.status).toBe('success');
expect(result.expectedResult?.returnValue).toBe('mock_xdr_base64');
expect(result.gasEstimate?.estimatedFee).toBe('100');
});

it('should return error prediction on simulation failure', async () => {
const mockResponse = {
error: 'balance',
};

clientMock.simulateTransaction.mockResolvedValue(mockResponse);

const rpcApiMock = {
isSimulationSuccess: jest.fn().mockReturnValue(false),
isSimulationError: jest.fn().mockReturnValue(true),
};
(global as any).rpc = { Api: rpcApiMock };

const dto: SimulateTransactionDto = {
network: 'testnet',
transactionXdr: 'mock_tx_xdr',
};

const result = await service.simulateTransaction(dto);

expect(result.success).toBe(false);
expect(result.predictedErrors).toContain(SimulationErrorCode.INSUFFICIENT_BALANCE);
});
});
Loading