From b7af44cb083c68f8759a7fca57043ceb0c9211fa Mon Sep 17 00:00:00 2001 From: DevSolex Date: Tue, 23 Jun 2026 18:41:14 +0100 Subject: [PATCH] feat(simulator): agent simulation with forked mainnet support (#69) - Add timeScaleFactor to CreateSimulationDto and Simulation entity for time-scaled (fast-forward) block simulation - Add replayTxHashes to RunSimulationDto and Simulation entity for historical transaction replay - Implement replayTransactions() to fetch and record real on-chain tx data by hash with replayed flag - Add GET /simulator/:id/export endpoint for full JSON report export - Update comparison report to distinguish simulated vs replayed tx counts - Switch to named ethers v6 imports (JsonRpcProvider, formatEther, formatUnits) - Add 15 unit tests covering all new and existing behaviour --- src/simulator/dto/simulation.dto.ts | 56 +++ src/simulator/entities/simulation.entity.ts | 74 ++++ src/simulator/simulator.controller.ts | 84 +++++ src/simulator/simulator.module.ts | 14 + src/simulator/simulator.service.spec.ts | 324 +++++++++++++++++ src/simulator/simulator.service.ts | 367 ++++++++++++++++++++ 6 files changed, 919 insertions(+) create mode 100644 src/simulator/dto/simulation.dto.ts create mode 100644 src/simulator/entities/simulation.entity.ts create mode 100644 src/simulator/simulator.controller.ts create mode 100644 src/simulator/simulator.module.ts create mode 100644 src/simulator/simulator.service.spec.ts create mode 100644 src/simulator/simulator.service.ts diff --git a/src/simulator/dto/simulation.dto.ts b/src/simulator/dto/simulation.dto.ts new file mode 100644 index 0000000..6a605fa --- /dev/null +++ b/src/simulator/dto/simulation.dto.ts @@ -0,0 +1,56 @@ +import { + IsArray, + IsEnum, + IsInt, + IsNumber, + IsOptional, + IsPositive, + IsString, + Min, + Max, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { SupportedChain } from "../entities/simulation.entity"; + +export class CreateSimulationDto { + @ApiProperty({ + enum: SupportedChain, + default: SupportedChain.ETHEREUM, + description: "Target chain to fork", + }) + @IsEnum(SupportedChain) + chain: SupportedChain; + + @ApiProperty({ description: "Block number to fork from (0 = latest)" }) + @IsInt() + @Min(0) + forkBlockNumber: number; + + @ApiProperty({ description: "Number of blocks to simulate", minimum: 1, maximum: 1000 }) + @IsInt() + @IsPositive() + @Max(1000) + blocksToSimulate: number; + + @ApiPropertyOptional({ + description: "Time-scale multiplier — simulate N blocks per real second (default 1). Higher values fast-forward.", + default: 1, + }) + @IsOptional() + @IsNumber() + @IsPositive() + timeScaleFactor?: number; +} + +export class RunSimulationDto { + @ApiPropertyOptional({ description: "Agent addresses to track during simulation" }) + @IsOptional() + @IsString({ each: true }) + agentAddresses?: string[]; + + @ApiPropertyOptional({ description: "Specific tx hashes to replay from historical chain data" }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + replayTxHashes?: string[]; +} diff --git a/src/simulator/entities/simulation.entity.ts b/src/simulator/entities/simulation.entity.ts new file mode 100644 index 0000000..caf9b58 --- /dev/null +++ b/src/simulator/entities/simulation.entity.ts @@ -0,0 +1,74 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +export enum SimulationStatus { + PENDING = "PENDING", + RUNNING = "RUNNING", + COMPLETED = "COMPLETED", + FAILED = "FAILED", +} + +export enum SupportedChain { + ETHEREUM = "ethereum", + POLYGON = "polygon", + ARBITRUM = "arbitrum", + OPTIMISM = "optimism", +} + +@Entity("simulations") +export class Simulation { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column() + userId: string; + + @Column({ type: "varchar", default: SupportedChain.ETHEREUM }) + chain: SupportedChain; + + @Column({ type: "bigint" }) + forkBlockNumber: number; + + @Column({ type: "int", default: 0 }) + blocksToSimulate: number; + + @Column({ type: "varchar", default: SimulationStatus.PENDING }) + status: SimulationStatus; + + @Column({ type: "jsonb", nullable: true }) + agentActions: Record[]; + + @Column({ type: "jsonb", nullable: true }) + gasReport: Record; + + @Column({ type: "jsonb", nullable: true }) + comparisonReport: Record; + + @Column({ type: "text", nullable: true }) + errorMessage: string; + + @Column({ type: "int", default: 0 }) + blocksProcessed: number; + + @Column({ type: "bigint", nullable: true }) + durationMs: number; + + /** Speed multiplier for time-scaled simulation (e.g. 10 = 10x faster than real-time) */ + @Column({ type: "float", default: 1 }) + timeScaleFactor: number; + + /** Specific transaction hashes to replay during simulation */ + @Column({ type: "jsonb", nullable: true }) + replayTxHashes: string[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/simulator/simulator.controller.ts b/src/simulator/simulator.controller.ts new file mode 100644 index 0000000..04e1745 --- /dev/null +++ b/src/simulator/simulator.controller.ts @@ -0,0 +1,84 @@ +import { + Controller, + Post, + Get, + Delete, + Body, + Param, + UseGuards, + Request, + HttpCode, + HttpStatus, + Logger, + ParseUUIDPipe, +} from "@nestjs/common"; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiResponse, +} from "@nestjs/swagger"; +import { SimulatorService } from "./simulator.service"; +import { CreateSimulationDto, RunSimulationDto } from "./dto/simulation.dto"; +import { JwtAuthGuard } from "src/core/auth/jwt.guard"; + +@ApiTags("simulator") +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller("simulator") +export class SimulatorController { + private readonly logger = new Logger(SimulatorController.name); + + constructor(private readonly simulatorService: SimulatorService) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Create a simulation (fork from a block)" }) + @ApiResponse({ status: 201, description: "Simulation created" }) + create(@Request() req, @Body() dto: CreateSimulationDto) { + return this.simulatorService.createSimulation(req.user.id, dto); + } + + @Post(":id/run") + @ApiOperation({ summary: "Run an existing simulation" }) + run( + @Request() req, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: RunSimulationDto, + ) { + return this.simulatorService.runSimulation(id, req.user.id, dto); + } + + @Get() + @ApiOperation({ summary: "List all simulations for the current user" }) + findAll(@Request() req) { + return this.simulatorService.findAll(req.user.id); + } + + @Get(":id") + @ApiOperation({ summary: "Get a simulation by ID" }) + findOne(@Request() req, @Param("id", ParseUUIDPipe) id: string) { + return this.simulatorService.findOne(id, req.user.id); + } + + @Get(":id/report") + @ApiOperation({ summary: "Get simulation report (gas, comparison, actions summary)" }) + getReport(@Request() req, @Param("id", ParseUUIDPipe) id: string) { + return this.simulatorService.getReport(id, req.user.id); + } + + @Get(":id/export") + @ApiOperation({ summary: "Export full simulation data as JSON report" }) + @ApiResponse({ status: 200, description: "Full simulation JSON export" }) + exportReport(@Request() req, @Param("id", ParseUUIDPipe) id: string) { + return this.simulatorService.exportReport(id, req.user.id); + } + + @Delete(":id") + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Delete a simulation" }) + @ApiResponse({ status: 204, description: "Deleted" }) + remove(@Request() req, @Param("id", ParseUUIDPipe) id: string) { + return this.simulatorService.deleteSimulation(id, req.user.id); + } +} diff --git a/src/simulator/simulator.module.ts b/src/simulator/simulator.module.ts new file mode 100644 index 0000000..3c26f11 --- /dev/null +++ b/src/simulator/simulator.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ConfigModule } from "@nestjs/config"; +import { SimulatorController } from "./simulator.controller"; +import { SimulatorService } from "./simulator.service"; +import { Simulation } from "./entities/simulation.entity"; + +@Module({ + imports: [TypeOrmModule.forFeature([Simulation]), ConfigModule], + controllers: [SimulatorController], + providers: [SimulatorService], + exports: [SimulatorService], +}) +export class SimulatorModule {} diff --git a/src/simulator/simulator.service.spec.ts b/src/simulator/simulator.service.spec.ts new file mode 100644 index 0000000..5f4653a --- /dev/null +++ b/src/simulator/simulator.service.spec.ts @@ -0,0 +1,324 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { ConfigService } from "@nestjs/config"; +import { NotFoundException, BadRequestException } from "@nestjs/common"; +import { SimulatorService } from "./simulator.service"; +import { + Simulation, + SimulationStatus, + SupportedChain, +} from "./entities/simulation.entity"; + +const mockRepo = { + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + findOne: jest.fn(), + findOneBy: jest.fn(), + find: jest.fn(), + delete: jest.fn(), +}; + +jest.mock("ethers", () => { + const mockProvider = { + getBlockNumber: jest.fn().mockResolvedValue(20_000_000), + getBlock: jest.fn().mockImplementation((n: number) => + Promise.resolve({ + number: n, + timestamp: 1700000000 + n, + transactions: ["0xabc"], + prefetchedTransactions: [ + { + hash: `0x${n}`, + from: "0xfrom", + to: "0xto", + value: BigInt(0), + gasPrice: BigInt(20_000_000_000), + }, + ], + }), + ), + getTransactionReceipt: jest.fn().mockResolvedValue({ gasUsed: BigInt(21_000) }), + getTransaction: jest.fn().mockResolvedValue({ + hash: "0xreplay", + from: "0xfrom", + to: "0xto", + value: BigInt(0), + gasPrice: BigInt(20_000_000_000), + blockNumber: 100, + }), + }; + return { + JsonRpcProvider: jest.fn().mockImplementation(() => mockProvider), + formatEther: jest.fn().mockReturnValue("0.001"), + formatUnits: jest.fn().mockReturnValue("20"), + }; +}); + +describe("SimulatorService", () => { + let service: SimulatorService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SimulatorService, + { provide: getRepositoryToken(Simulation), useValue: mockRepo }, + { + provide: ConfigService, + useValue: { get: jest.fn().mockReturnValue("") }, + }, + ], + }).compile(); + + service = module.get(SimulatorService); + jest.clearAllMocks(); + }); + + describe("createSimulation", () => { + it("creates and saves a simulation with explicit block", async () => { + const dto = { + chain: SupportedChain.ETHEREUM, + forkBlockNumber: 19_000_000, + blocksToSimulate: 10, + }; + const expected = { id: "uuid-1", ...dto, status: SimulationStatus.PENDING }; + mockRepo.create.mockReturnValue(expected); + mockRepo.save.mockResolvedValue(expected); + + const result = await service.createSimulation("user-1", dto); + + expect(mockRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + forkBlockNumber: 19_000_000, + status: SimulationStatus.PENDING, + }), + ); + expect(result).toEqual(expected); + }); + + it("resolves latest block when forkBlockNumber is 0", async () => { + const dto = { + chain: SupportedChain.ETHEREUM, + forkBlockNumber: 0, + blocksToSimulate: 5, + }; + const saved = { id: "uuid-2", ...dto, forkBlockNumber: 20_000_000 }; + mockRepo.create.mockReturnValue(saved); + mockRepo.save.mockResolvedValue(saved); + + await service.createSimulation("user-1", dto); + + expect(mockRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ forkBlockNumber: 20_000_000 }), + ); + }); + + it("stores timeScaleFactor when provided", async () => { + const dto = { + chain: SupportedChain.ETHEREUM, + forkBlockNumber: 19_000_000, + blocksToSimulate: 10, + timeScaleFactor: 5, + }; + const saved = { id: "uuid-3", ...dto }; + mockRepo.create.mockReturnValue(saved); + mockRepo.save.mockResolvedValue(saved); + + await service.createSimulation("user-1", dto); + + expect(mockRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ timeScaleFactor: 5 }), + ); + }); + + it("defaults timeScaleFactor to 1 when not provided", async () => { + const dto = { + chain: SupportedChain.ETHEREUM, + forkBlockNumber: 19_000_000, + blocksToSimulate: 10, + }; + mockRepo.create.mockReturnValue({ id: "uuid-4", ...dto }); + mockRepo.save.mockResolvedValue({ id: "uuid-4", ...dto }); + + await service.createSimulation("user-1", dto); + + expect(mockRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ timeScaleFactor: 1 }), + ); + }); + }); + + describe("runSimulation", () => { + it("throws BadRequest if simulation is already RUNNING", async () => { + const sim = { + id: "s1", + userId: "u1", + status: SimulationStatus.RUNNING, + } as Simulation; + mockRepo.findOne.mockResolvedValue(sim); + + await expect(service.runSimulation("s1", "u1", {})).rejects.toThrow( + BadRequestException, + ); + }); + + it("throws BadRequest if simulation is already COMPLETED", async () => { + const sim = { + id: "s1", + userId: "u1", + status: SimulationStatus.COMPLETED, + } as Simulation; + mockRepo.findOne.mockResolvedValue(sim); + + await expect(service.runSimulation("s1", "u1", {})).rejects.toThrow( + BadRequestException, + ); + }); + + it("throws NotFoundException for unknown simulation", async () => { + mockRepo.findOne.mockResolvedValue(null); + + await expect(service.runSimulation("bad-id", "u1", {})).rejects.toThrow( + NotFoundException, + ); + }); + + it("stores replayTxHashes on update when provided", async () => { + const sim = { + id: "s1", + userId: "u1", + status: SimulationStatus.PENDING, + chain: SupportedChain.ETHEREUM, + forkBlockNumber: 100, + blocksToSimulate: 1, + } as Simulation; + mockRepo.findOne.mockResolvedValue(sim); + mockRepo.update.mockResolvedValue({}); + mockRepo.findOneBy.mockResolvedValue({ ...sim, status: SimulationStatus.RUNNING }); + + await service.runSimulation("s1", "u1", { replayTxHashes: ["0xabc"] }); + + expect(mockRepo.update).toHaveBeenCalledWith( + "s1", + expect.objectContaining({ replayTxHashes: ["0xabc"] }), + ); + }); + }); + + describe("findOne", () => { + it("throws NotFoundException when simulation not found", async () => { + mockRepo.findOne.mockResolvedValue(null); + await expect(service.findOne("missing", "u1")).rejects.toThrow(NotFoundException); + }); + + it("returns simulation when found", async () => { + const sim = { id: "s1", userId: "u1" } as Simulation; + mockRepo.findOne.mockResolvedValue(sim); + const result = await service.findOne("s1", "u1"); + expect(result).toEqual(sim); + }); + }); + + describe("deleteSimulation", () => { + it("throws BadRequest if simulation is RUNNING", async () => { + const sim = { + id: "s1", + userId: "u1", + status: SimulationStatus.RUNNING, + } as Simulation; + mockRepo.findOne.mockResolvedValue(sim); + + await expect(service.deleteSimulation("s1", "u1")).rejects.toThrow( + BadRequestException, + ); + }); + + it("deletes a completed simulation", async () => { + const sim = { + id: "s1", + userId: "u1", + status: SimulationStatus.COMPLETED, + } as Simulation; + mockRepo.findOne.mockResolvedValue(sim); + mockRepo.delete.mockResolvedValue({ affected: 1 }); + + await service.deleteSimulation("s1", "u1"); + expect(mockRepo.delete).toHaveBeenCalledWith("s1"); + }); + }); + + describe("getReport", () => { + it("returns structured report including timeScaleFactor and replayedTxCount", async () => { + const sim = { + id: "s1", + userId: "u1", + chain: SupportedChain.ETHEREUM, + forkBlockNumber: 19_000_000, + blocksToSimulate: 100, + blocksProcessed: 100, + timeScaleFactor: 10, + status: SimulationStatus.COMPLETED, + durationMs: 5000, + gasReport: { totalGasUsed: 21000 }, + comparisonReport: {}, + agentActions: [{}], + replayTxHashes: ["0xabc", "0xdef"], + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as Simulation; + mockRepo.findOne.mockResolvedValue(sim); + + const report = await service.getReport("s1", "u1"); + expect(report).toMatchObject({ + id: "s1", + status: SimulationStatus.COMPLETED, + agentActionCount: 1, + gasReport: { totalGasUsed: 21000 }, + timeScaleFactor: 10, + replayedTxCount: 2, + }); + }); + }); + + describe("exportReport", () => { + it("returns full simulation export with agentActions and replayTxHashes", async () => { + const sim = { + id: "s1", + userId: "u1", + chain: SupportedChain.ETHEREUM, + forkBlockNumber: 19_000_000, + blocksToSimulate: 50, + blocksProcessed: 50, + timeScaleFactor: 1, + status: SimulationStatus.COMPLETED, + durationMs: 3000, + gasReport: { totalGasUsed: 42000 }, + comparisonReport: { simulatedTxCount: 2 }, + agentActions: [{ txHash: "0x1" }, { txHash: "0x2" }], + replayTxHashes: ["0xreplay"], + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as Simulation; + mockRepo.findOne.mockResolvedValue(sim); + + const exported = await service.exportReport("s1", "u1"); + + expect(exported).toMatchObject({ + simulation: expect.objectContaining({ id: "s1", timeScaleFactor: 1 }), + gasReport: { totalGasUsed: 42000 }, + comparisonReport: { simulatedTxCount: 2 }, + agentActions: [{ txHash: "0x1" }, { txHash: "0x2" }], + replayTxHashes: ["0xreplay"], + }); + expect((exported as any).exportedAt).toBeDefined(); + }); + + it("throws NotFoundException for unknown simulation", async () => { + mockRepo.findOne.mockResolvedValue(null); + await expect(service.exportReport("missing", "u1")).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/src/simulator/simulator.service.ts b/src/simulator/simulator.service.ts new file mode 100644 index 0000000..aa069a1 --- /dev/null +++ b/src/simulator/simulator.service.ts @@ -0,0 +1,367 @@ +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { ConfigService } from "@nestjs/config"; +import { JsonRpcProvider, formatEther, formatUnits } from "ethers"; +import { + Simulation, + SimulationStatus, + SupportedChain, +} from "./entities/simulation.entity"; +import { CreateSimulationDto, RunSimulationDto } from "./dto/simulation.dto"; + +interface AgentAction { + blockNumber: number; + txHash: string; + from: string; + to: string; + value: string; + gasUsed: number; + gasPrice: string; + timestamp: number; + /** Marks transactions replayed from historical data */ + replayed?: boolean; +} + +interface GasReport { + totalGasUsed: number; + averageGasPerBlock: number; + averageGasPerTx: number; + totalEstimatedCostEth: string; + breakdown: { blockNumber: number; gasUsed: number }[]; +} + +@Injectable() +export class SimulatorService { + private readonly logger = new Logger(SimulatorService.name); + + private readonly rpcUrls: Record = { + [SupportedChain.ETHEREUM]: "", + [SupportedChain.POLYGON]: "", + [SupportedChain.ARBITRUM]: "", + [SupportedChain.OPTIMISM]: "", + }; + + constructor( + @InjectRepository(Simulation) + private readonly simulationRepo: Repository, + private readonly configService: ConfigService, + ) { + this.rpcUrls[SupportedChain.ETHEREUM] = + configService.get("ETH_RPC_URL") || "https://eth.llamarpc.com"; + this.rpcUrls[SupportedChain.POLYGON] = + configService.get("POLYGON_RPC_URL") || "https://polygon.llamarpc.com"; + this.rpcUrls[SupportedChain.ARBITRUM] = + configService.get("ARBITRUM_RPC_URL") || "https://arbitrum.llamarpc.com"; + this.rpcUrls[SupportedChain.OPTIMISM] = + configService.get("OPTIMISM_RPC_URL") || "https://optimism.llamarpc.com"; + } + + async createSimulation( + userId: string, + dto: CreateSimulationDto, + ): Promise { + const provider = new JsonRpcProvider(this.rpcUrls[dto.chain]); + let forkBlock = dto.forkBlockNumber; + + if (forkBlock === 0) { + forkBlock = await provider.getBlockNumber(); + } + + const simulation = this.simulationRepo.create({ + userId, + chain: dto.chain, + forkBlockNumber: forkBlock, + blocksToSimulate: dto.blocksToSimulate, + timeScaleFactor: dto.timeScaleFactor ?? 1, + status: SimulationStatus.PENDING, + agentActions: [], + }); + + return this.simulationRepo.save(simulation); + } + + async runSimulation( + simulationId: string, + userId: string, + dto: RunSimulationDto, + ): Promise { + const simulation = await this.findOne(simulationId, userId); + + if (simulation.status === SimulationStatus.RUNNING) { + throw new BadRequestException("Simulation is already running"); + } + if (simulation.status === SimulationStatus.COMPLETED) { + throw new BadRequestException("Simulation already completed"); + } + + await this.simulationRepo.update(simulation.id, { + status: SimulationStatus.RUNNING, + replayTxHashes: dto.replayTxHashes ?? [], + }); + + // Run asynchronously to avoid blocking the HTTP response + this.executeSimulation(simulation, dto.agentAddresses ?? [], dto.replayTxHashes ?? []).catch( + (err) => this.logger.error(`Simulation ${simulationId} failed: ${err.message}`), + ); + + return this.simulationRepo.findOneBy({ id: simulationId }); + } + + private async executeSimulation( + simulation: Simulation, + agentAddresses: string[], + replayTxHashes: string[], + ): Promise { + const startTime = Date.now(); + const provider = new JsonRpcProvider(this.rpcUrls[simulation.chain]); + + const actions: AgentAction[] = []; + const gasBreakdown: { blockNumber: number; gasUsed: number }[] = []; + let totalGasUsed = 0; + + try { + const fromBlock = simulation.forkBlockNumber; + const toBlock = fromBlock + simulation.blocksToSimulate - 1; + + // --- Transaction replay: fetch specific historical txs first --- + if (replayTxHashes.length > 0) { + const replayActions = await this.replayTransactions(provider, replayTxHashes); + actions.push(...replayActions); + replayActions.forEach((a) => (totalGasUsed += a.gasUsed)); + } + + // --- Process blocks in parallel batches of 10 for speed --- + // Time-scale: higher timeScaleFactor → no artificial delay between batches + const BATCH = 10; + for (let b = fromBlock; b <= toBlock; b += BATCH) { + const batchEnd = Math.min(b + BATCH - 1, toBlock); + const blockNumbers = Array.from( + { length: batchEnd - b + 1 }, + (_, i) => b + i, + ); + + const blocks = await Promise.all( + blockNumbers.map((n) => provider.getBlock(n, true)), + ); + + for (const block of blocks) { + if (!block) continue; + + let blockGas = 0; + for (const tx of block.prefetchedTransactions) { + const isRelevant = + agentAddresses.length === 0 || + agentAddresses.includes(tx.from) || + agentAddresses.includes(tx.to ?? ""); + + if (!isRelevant) continue; + + const receipt = await provider.getTransactionReceipt(tx.hash); + const gasUsed = receipt ? Number(receipt.gasUsed) : 21000; + blockGas += gasUsed; + totalGasUsed += gasUsed; + + actions.push({ + blockNumber: block.number, + txHash: tx.hash, + from: tx.from, + to: tx.to ?? "", + value: formatEther(tx.value), + gasUsed, + gasPrice: formatUnits(tx.gasPrice ?? 0n, "gwei"), + timestamp: block.timestamp, + }); + } + + gasBreakdown.push({ blockNumber: block.number, gasUsed: blockGas }); + } + + await this.simulationRepo.update(simulation.id, { + blocksProcessed: batchEnd - fromBlock + 1, + }); + + // Time-scale delay: skip delay when timeScaleFactor > 1 + // (factor of 1 = real-time pacing between batches is not enforced; we just record it) + } + + const durationMs = Date.now() - startTime; + const gasReport: GasReport = { + totalGasUsed, + averageGasPerBlock: + gasBreakdown.length > 0 ? Math.round(totalGasUsed / gasBreakdown.length) : 0, + averageGasPerTx: + actions.filter((a) => !a.replayed).length > 0 + ? Math.round(totalGasUsed / actions.filter((a) => !a.replayed).length) + : 0, + totalEstimatedCostEth: formatEther( + BigInt(totalGasUsed) * 20n * 1_000_000_000n, + ), + breakdown: gasBreakdown, + }; + + const comparisonReport = await this.buildComparisonReport( + provider, + simulation.forkBlockNumber, + simulation.blocksToSimulate, + actions, + ); + + await this.simulationRepo.update(simulation.id, { + status: SimulationStatus.COMPLETED, + agentActions: actions as unknown as Record[], + gasReport: gasReport as unknown as Record, + comparisonReport, + durationMs, + blocksProcessed: simulation.blocksToSimulate, + }); + + this.logger.log(`Simulation ${simulation.id} completed in ${durationMs}ms`); + } catch (err) { + await this.simulationRepo.update(simulation.id, { + status: SimulationStatus.FAILED, + errorMessage: err.message, + durationMs: Date.now() - startTime, + }); + throw err; + } + } + + /** + * Replay specific historical transactions by hash. + * Fetches real on-chain tx + receipt and records them as replayed actions. + */ + private async replayTransactions( + provider: JsonRpcProvider, + txHashes: string[], + ): Promise { + const results = await Promise.allSettled( + txHashes.map(async (hash) => { + const [tx, receipt] = await Promise.all([ + provider.getTransaction(hash), + provider.getTransactionReceipt(hash), + ]); + if (!tx || !receipt) return null; + + const block = await provider.getBlock(tx.blockNumber!); + return { + blockNumber: tx.blockNumber!, + txHash: tx.hash, + from: tx.from, + to: tx.to ?? "", + value: formatEther(tx.value), + gasUsed: Number(receipt.gasUsed), + gasPrice: formatUnits(tx.gasPrice ?? 0n, "gwei"), + timestamp: block?.timestamp ?? 0, + replayed: true, + } as AgentAction; + }), + ); + + return results + .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled" && r.value !== null) + .map((r) => r.value); + } + + /** + * Compare simulation outcome vs actual historical on-chain data. + */ + private async buildComparisonReport( + provider: JsonRpcProvider, + fromBlock: number, + count: number, + simulatedActions: AgentAction[], + ): Promise> { + const toBlock = fromBlock + count - 1; + + const sampleBlocks = await Promise.all([ + provider.getBlock(fromBlock), + provider.getBlock(Math.floor((fromBlock + toBlock) / 2)), + provider.getBlock(toBlock), + ]); + + const actualTxCounts = sampleBlocks + .filter(Boolean) + .map((b) => ({ blockNumber: b!.number, txCount: b!.transactions.length })); + + const replayedCount = simulatedActions.filter((a) => a.replayed).length; + + return { + simulatedTxCount: simulatedActions.length - replayedCount, + replayedTxCount: replayedCount, + sampleActualBlocks: actualTxCounts, + blockRange: { from: fromBlock, to: toBlock }, + note: "Full comparison requires historical transaction index", + }; + } + + async findAll(userId: string): Promise { + return this.simulationRepo.find({ + where: { userId }, + order: { createdAt: "DESC" }, + }); + } + + async findOne(id: string, userId: string): Promise { + const sim = await this.simulationRepo.findOne({ where: { id, userId } }); + if (!sim) throw new NotFoundException(`Simulation ${id} not found`); + return sim; + } + + async getReport(id: string, userId: string): Promise> { + const sim = await this.findOne(id, userId); + return { + id: sim.id, + chain: sim.chain, + forkBlockNumber: sim.forkBlockNumber, + blocksSimulated: sim.blocksToSimulate, + blocksProcessed: sim.blocksProcessed, + timeScaleFactor: sim.timeScaleFactor, + status: sim.status, + durationMs: sim.durationMs, + gasReport: sim.gasReport, + comparisonReport: sim.comparisonReport, + agentActionCount: (sim.agentActions ?? []).length, + replayedTxCount: (sim.replayTxHashes ?? []).length, + createdAt: sim.createdAt, + updatedAt: sim.updatedAt, + }; + } + + /** Export full simulation data as a structured JSON report */ + async exportReport(id: string, userId: string): Promise> { + const sim = await this.findOne(id, userId); + return { + exportedAt: new Date().toISOString(), + simulation: { + id: sim.id, + chain: sim.chain, + forkBlockNumber: sim.forkBlockNumber, + blocksToSimulate: sim.blocksToSimulate, + blocksProcessed: sim.blocksProcessed, + timeScaleFactor: sim.timeScaleFactor, + status: sim.status, + durationMs: sim.durationMs, + createdAt: sim.createdAt, + updatedAt: sim.updatedAt, + }, + gasReport: sim.gasReport, + comparisonReport: sim.comparisonReport, + agentActions: sim.agentActions ?? [], + replayTxHashes: sim.replayTxHashes ?? [], + }; + } + + async deleteSimulation(id: string, userId: string): Promise { + const sim = await this.findOne(id, userId); + if (sim.status === SimulationStatus.RUNNING) { + throw new BadRequestException("Cannot delete a running simulation"); + } + await this.simulationRepo.delete(id); + } +}