diff --git a/.env.example b/.env.example index deaaba4..012bc5f 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,7 @@ LLAMA_API_BASE_URL=http://localhost:8000 # Blockchain ETH_RPC_URL=https://mainnet.infura.io/v3/your-project-id +BSC_RPC_URL=https://bsc-dataseed.binance.org CHAIN_ID=1 # Redis (for caching and WebSocket) diff --git a/src/app.module.ts b/src/app.module.ts index 2c91b7f..93954f8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -55,6 +55,7 @@ import { Wallet } from "./core/auth/entities/wallet.entity"; // Oracle entities import { SignedPayload } from "./blockchain/oracle/entities/signed-payload.entity"; import { SubmissionNonce } from "./blockchain/oracle/entities/submission-nonce.entity"; +import { PriceRecord } from "./blockchain/oracle/entities/price-record.entity"; // Audit entities import { AgentEvent } from "./infrastructure/audit/entities/agent-event.entity"; @@ -145,6 +146,7 @@ import { ProfilingMiddleware } from "./profiling/profiling.middleware"; Wallet, SignedPayload, SubmissionNonce, + PriceRecord, AgentEvent, ComputeResult, ProvenanceRecord, diff --git a/src/blockchain/oracle/dto/price-feed.dto.ts b/src/blockchain/oracle/dto/price-feed.dto.ts new file mode 100644 index 0000000..975caa9 --- /dev/null +++ b/src/blockchain/oracle/dto/price-feed.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEnum, IsString, IsOptional, IsInt, Min, Max } from "class-validator"; +import { SupportedChain, PriceSource } from "../entities/price-record.entity"; + +export class GetPriceDto { + @ApiProperty({ example: "ETH", description: "Asset symbol" }) + @IsString() + asset: string; + + @ApiProperty({ enum: SupportedChain, example: SupportedChain.ETHEREUM }) + @IsEnum(SupportedChain) + chain: SupportedChain; +} + +export class GetHistoricalPricesDto { + @ApiProperty({ example: "ETH" }) + @IsString() + asset: string; + + @ApiProperty({ enum: SupportedChain }) + @IsEnum(SupportedChain) + chain: SupportedChain; + + @ApiPropertyOptional({ example: 100, default: 100, minimum: 1, maximum: 1000 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(1000) + limit?: number = 100; +} + +export class SourcePriceDto { + @ApiPropertyOptional({ example: 2000.5 }) + chainlink?: number; + + @ApiPropertyOptional({ example: 2001.0 }) + band?: number; + + @ApiPropertyOptional({ example: 1999.8 }) + uniswap_twap?: number; +} + +export class PriceResponseDto { + @ApiProperty({ example: "ETH" }) + asset: string; + + @ApiProperty({ enum: SupportedChain }) + chain: SupportedChain; + + @ApiProperty({ example: 2000.43 }) + price: number; + + @ApiProperty({ type: SourcePriceDto }) + sourcePrices: Record; + + @ApiProperty({ example: false }) + deviationAlert: boolean; + + @ApiProperty({ example: 0.0612 }) + maxDeviationPercent: number; + + @ApiProperty() + timestamp: Date; +} diff --git a/src/blockchain/oracle/entities/price-record.entity.ts b/src/blockchain/oracle/entities/price-record.entity.ts new file mode 100644 index 0000000..67568f1 --- /dev/null +++ b/src/blockchain/oracle/entities/price-record.entity.ts @@ -0,0 +1,56 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from "typeorm"; + +export enum SupportedChain { + ETHEREUM = "ethereum", + BSC = "bsc", + POLYGON = "polygon", + ARBITRUM = "arbitrum", + OPTIMISM = "optimism", + AVALANCHE = "avalanche", +} + +export enum PriceSource { + CHAINLINK = "chainlink", + BAND = "band", + UNISWAP_TWAP = "uniswap_twap", +} + +@Entity("price_records") +@Index(["asset", "chain", "createdAt"]) +@Index(["asset", "createdAt"]) +export class PriceRecord { + @PrimaryGeneratedColumn("uuid") + id: string; + + /** Asset symbol, e.g. "ETH", "BTC" */ + @Column({ type: "varchar", length: 20 }) + asset: string; + + @Column({ type: "enum", enum: SupportedChain }) + chain: SupportedChain; + + /** Canonical median price in USD */ + @Column({ type: "decimal", precision: 30, scale: 8 }) + price: number; + + /** Raw prices per source, e.g. { chainlink: 2000.5, band: 2001.0, uniswap_twap: 1999.8 } */ + @Column({ type: "jsonb" }) + sourcePrices: Record; + + /** Whether a >5% deviation was detected between sources */ + @Column({ type: "boolean", default: false }) + deviationAlert: boolean; + + /** Max % deviation observed between sources */ + @Column({ type: "decimal", precision: 8, scale: 4, default: 0 }) + maxDeviationPercent: number; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/blockchain/oracle/oracle.module.ts b/src/blockchain/oracle/oracle.module.ts index 3abe040..81ebc06 100644 --- a/src/blockchain/oracle/oracle.module.ts +++ b/src/blockchain/oracle/oracle.module.ts @@ -8,8 +8,11 @@ import { NonceManagementService } from "./services/nonce-management.service"; import { SubmitterService } from "./services/submitter.service"; import { SubmissionBatchService } from "./services/submission-batch.service"; import { SubmissionVerifierService } from "./submission-verifier.service"; +import { PriceFeedService } from "./services/price-feed.service"; +import { PriceFeedController } from "./price-feed.controller"; import { SignedPayload } from "./entities/signed-payload.entity"; import { SubmissionNonce } from "./entities/submission-nonce.entity"; +import { PriceRecord } from "./entities/price-record.entity"; import { AuditModule } from "src/infrastructure/audit/audit.module"; /** @@ -18,11 +21,11 @@ import { AuditModule } from "src/infrastructure/audit/audit.module"; */ @Module({ imports: [ - TypeOrmModule.forFeature([SignedPayload, SubmissionNonce]), + TypeOrmModule.forFeature([SignedPayload, SubmissionNonce, PriceRecord]), ConfigModule, AuditModule, ], - controllers: [OracleController], + controllers: [OracleController, PriceFeedController], providers: [ OracleService, PayloadSigningService, @@ -30,6 +33,7 @@ import { AuditModule } from "src/infrastructure/audit/audit.module"; SubmitterService, SubmissionBatchService, SubmissionVerifierService, + PriceFeedService, ], exports: [ OracleService, @@ -38,6 +42,7 @@ import { AuditModule } from "src/infrastructure/audit/audit.module"; SubmitterService, SubmissionBatchService, SubmissionVerifierService, + PriceFeedService, ], }) export class OracleModule {} diff --git a/src/blockchain/oracle/price-feed.controller.ts b/src/blockchain/oracle/price-feed.controller.ts new file mode 100644 index 0000000..46612b4 --- /dev/null +++ b/src/blockchain/oracle/price-feed.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Param, Query } from "@nestjs/common"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { PriceFeedService } from "./services/price-feed.service"; +import { + GetHistoricalPricesDto, + PriceResponseDto, +} from "./dto/price-feed.dto"; +import { SupportedChain } from "./entities/price-record.entity"; + +@ApiTags("Price Feed") +@Controller("price-feed") +export class PriceFeedController { + constructor(private readonly priceFeedService: PriceFeedService) {} + + @Get(":chain/:asset") + @ApiOperation({ summary: "Get current aggregated price for an asset on a chain" }) + @ApiResponse({ status: 200, type: PriceResponseDto }) + async getCurrentPrice( + @Param("chain") chain: SupportedChain, + @Param("asset") asset: string, + ): Promise { + return this.priceFeedService.getCurrentPrice(asset, chain); + } + + @Get(":chain/:asset/history") + @ApiOperation({ summary: "Get historical prices for an asset on a chain" }) + @ApiResponse({ status: 200, type: [PriceResponseDto] }) + async getHistoricalPrices( + @Param("chain") chain: SupportedChain, + @Param("asset") asset: string, + @Query() query: GetHistoricalPricesDto, + ): Promise { + return this.priceFeedService.getHistoricalPrices( + asset, + chain, + query.limit, + ); + } +} diff --git a/src/blockchain/oracle/services/price-feed.service.spec.ts b/src/blockchain/oracle/services/price-feed.service.spec.ts new file mode 100644 index 0000000..09b6faa --- /dev/null +++ b/src/blockchain/oracle/services/price-feed.service.spec.ts @@ -0,0 +1,234 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { ConfigService } from "@nestjs/config"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { PriceFeedService } from "./price-feed.service"; +import { + PriceRecord, + SupportedChain, + PriceSource, +} from "../entities/price-record.entity"; + +const mockRepo = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), +}; + +const mockConfig = { get: jest.fn().mockReturnValue(undefined) }; +const mockEmitter = { emit: jest.fn() }; + +describe("PriceFeedService", () => { + let service: PriceFeedService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PriceFeedService, + { provide: getRepositoryToken(PriceRecord), useValue: mockRepo }, + { provide: ConfigService, useValue: mockConfig }, + { provide: EventEmitter2, useValue: mockEmitter }, + ], + }).compile(); + + service = module.get(PriceFeedService); + jest.clearAllMocks(); + }); + + describe("aggregatePrices", () => { + it("returns median of a single price with zero deviation", () => { + const { median, maxDeviationPercent } = service.aggregatePrices({ + [PriceSource.CHAINLINK]: 2000, + }); + expect(median).toBe(2000); + expect(maxDeviationPercent).toBe(0); + }); + + it("returns middle value as median for odd count", () => { + const { median } = service.aggregatePrices({ + [PriceSource.CHAINLINK]: 100, + [PriceSource.BAND]: 200, + [PriceSource.UNISWAP_TWAP]: 300, + }); + expect(median).toBe(200); + }); + + it("returns average of two middle values for even count", () => { + const { median } = service.aggregatePrices({ + [PriceSource.CHAINLINK]: 100, + [PriceSource.BAND]: 200, + }); + expect(median).toBe(150); + }); + + it("computes max pairwise deviation correctly", () => { + const { maxDeviationPercent } = service.aggregatePrices({ + [PriceSource.CHAINLINK]: 100, + [PriceSource.BAND]: 110, + }); + // avg=105, |100-110|/105*100 ≈ 9.52% + expect(maxDeviationPercent).toBeCloseTo(9.52, 1); + }); + + it("returns zero deviation when all prices are identical", () => { + const { maxDeviationPercent } = service.aggregatePrices({ + [PriceSource.CHAINLINK]: 2000, + [PriceSource.BAND]: 2000, + [PriceSource.UNISWAP_TWAP]: 2000, + }); + expect(maxDeviationPercent).toBe(0); + }); + + it("ignores non-positive values", () => { + const { median } = service.aggregatePrices({ + [PriceSource.CHAINLINK]: -10, + [PriceSource.BAND]: 0, + [PriceSource.UNISWAP_TWAP]: 500, + }); + expect(median).toBe(500); + }); + + it("returns zeros when prices object is empty", () => { + const { median, maxDeviationPercent } = service.aggregatePrices({}); + expect(median).toBe(0); + expect(maxDeviationPercent).toBe(0); + }); + }); + + describe("getCurrentPrice", () => { + it("throws when no RPC provider is configured for the chain", async () => { + await expect( + service.getCurrentPrice("ETH", SupportedChain.ETHEREUM), + ).rejects.toThrow(/No price sources available/); + }); + + it("persists a PriceRecord and returns a PriceResponseDto", async () => { + const fakePrices = { + [PriceSource.CHAINLINK]: 2000, + [PriceSource.BAND]: 2010, + }; + jest + .spyOn(service as any, "fetchAllSources") + .mockResolvedValue(fakePrices); + + const fakeRecord: Partial = { + asset: "ETH", + chain: SupportedChain.ETHEREUM, + price: 2005, + sourcePrices: fakePrices as Record, + deviationAlert: false, + maxDeviationPercent: 0.5, + createdAt: new Date("2026-01-01"), + }; + mockRepo.create.mockReturnValue(fakeRecord); + mockRepo.save.mockResolvedValue(fakeRecord); + + const result = await service.getCurrentPrice("ETH", SupportedChain.ETHEREUM); + + expect(mockRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ asset: "ETH", chain: SupportedChain.ETHEREUM }), + ); + expect(mockRepo.save).toHaveBeenCalledWith(fakeRecord); + expect(result.asset).toBe("ETH"); + expect(result.chain).toBe(SupportedChain.ETHEREUM); + }); + + it("emits price.deviation event when deviation exceeds 5%", async () => { + const fakePrices = { + [PriceSource.CHAINLINK]: 100, + [PriceSource.BAND]: 110, + }; + jest + .spyOn(service as any, "fetchAllSources") + .mockResolvedValue(fakePrices); + + const fakeRecord: Partial = { + asset: "ETH", + chain: SupportedChain.ETHEREUM, + price: 105, + sourcePrices: fakePrices as Record, + deviationAlert: true, + maxDeviationPercent: 9.52, + createdAt: new Date(), + }; + mockRepo.create.mockReturnValue(fakeRecord); + mockRepo.save.mockResolvedValue(fakeRecord); + + await service.getCurrentPrice("ETH", SupportedChain.ETHEREUM); + + expect(mockEmitter.emit).toHaveBeenCalledWith( + "price.deviation", + expect.objectContaining({ asset: "ETH", chain: SupportedChain.ETHEREUM }), + ); + }); + + it("does NOT emit deviation event when deviation is below 5%", async () => { + const fakePrices = { + [PriceSource.CHAINLINK]: 2000, + [PriceSource.BAND]: 2001, + }; + jest + .spyOn(service as any, "fetchAllSources") + .mockResolvedValue(fakePrices); + + const fakeRecord: Partial = { + asset: "ETH", + chain: SupportedChain.ETHEREUM, + price: 2000.5, + sourcePrices: fakePrices as Record, + deviationAlert: false, + maxDeviationPercent: 0.05, + createdAt: new Date(), + }; + mockRepo.create.mockReturnValue(fakeRecord); + mockRepo.save.mockResolvedValue(fakeRecord); + + await service.getCurrentPrice("ETH", SupportedChain.ETHEREUM); + expect(mockEmitter.emit).not.toHaveBeenCalled(); + }); + }); + + describe("getHistoricalPrices", () => { + it("queries repository with correct filters and returns mapped DTOs", async () => { + const records: Partial[] = [ + { + asset: "ETH", + chain: SupportedChain.ETHEREUM, + price: 2000, + sourcePrices: { + [PriceSource.CHAINLINK]: 2000, + } as Record, + deviationAlert: false, + maxDeviationPercent: 0, + createdAt: new Date("2026-01-01"), + }, + ]; + mockRepo.find.mockResolvedValue(records); + + const results = await service.getHistoricalPrices( + "ETH", + SupportedChain.ETHEREUM, + 50, + ); + + expect(mockRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { asset: "ETH", chain: SupportedChain.ETHEREUM }, + take: 50, + order: { createdAt: "DESC" }, + }), + ); + expect(results).toHaveLength(1); + expect(results[0].asset).toBe("ETH"); + expect(results[0].price).toBe(2000); + }); + + it("defaults to 100 records when no limit is given", async () => { + mockRepo.find.mockResolvedValue([]); + await service.getHistoricalPrices("BTC", SupportedChain.BSC); + expect(mockRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ take: 100 }), + ); + }); + }); +}); diff --git a/src/blockchain/oracle/services/price-feed.service.ts b/src/blockchain/oracle/services/price-feed.service.ts new file mode 100644 index 0000000..32306cd --- /dev/null +++ b/src/blockchain/oracle/services/price-feed.service.ts @@ -0,0 +1,305 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { ConfigService } from "@nestjs/config"; +import { JsonRpcProvider, Contract } from "ethers"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { + PriceRecord, + SupportedChain, + PriceSource, +} from "../entities/price-record.entity"; +import { PriceResponseDto } from "../dto/price-feed.dto"; + +/** Chainlink AggregatorV3Interface — only latestRoundData needed */ +const CHAINLINK_ABI = [ + "function latestRoundData() external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)", + "function decimals() external view returns (uint8)", +]; + +/** Band Protocol StdReference */ +const BAND_ABI = [ + "function getReferenceData(string base, string quote) external view returns (uint256 rate, uint256 lastUpdatedBase, uint256 lastUpdatedQuote)", +]; + +/** Uniswap V3 Pool — for TWAP via slot0 + observe */ +const UNISWAP_V3_POOL_ABI = [ + "function observe(uint32[] secondsAgos) external view returns (int56[] tickCumulatives, uint160[] secondsPerLiquidityCumulativeX128s)", + "function token0() external view returns (address)", + "function token1() external view returns (address)", +]; + +/** DEVIATION_THRESHOLD for alerting (5%) */ +const DEVIATION_THRESHOLD_PERCENT = 5; + +/** Well-known contract addresses per chain */ +const CHAIN_CONTRACTS: Record< + SupportedChain, + { + rpcEnvKey: string; + chainId: number; + chainlink: Record; + band?: string; + uniswapV3Pools?: Record; + } +> = { + [SupportedChain.ETHEREUM]: { + rpcEnvKey: "ETH_RPC_URL", + chainId: 1, + chainlink: { + ETH: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419", + BTC: "0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c", + LINK: "0x2c1d072e956AFFC0D435Cb7AC308d97e0D8adf3B", + }, + band: "0xDA7a001b254CD22e46d3eAB04d937489c93174C3", + uniswapV3Pools: { + ETH: "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640", // ETH/USDC 0.05% + }, + }, + [SupportedChain.BSC]: { + rpcEnvKey: "BSC_RPC_URL", + chainId: 56, + chainlink: { + BNB: "0x0567F2323251f0Aab15c8dFb1967E4e8A7D42aeE", + ETH: "0x9ef1B8c0E4F7dc8bF5719Ea496883DC6401d5b2e", + BTC: "0x264990fbd0A4796A3E3d8E37C4d5F87a3aCa5Ebf", + }, + band: "0xDA7a001b254CD22e46d3eAB04d937489c93174C3", + }, + [SupportedChain.POLYGON]: { + rpcEnvKey: "POLY_RPC_URL", + chainId: 137, + chainlink: { + ETH: "0xF9680D99D6C9589e2a93a78A04A279e509205945", + BTC: "0xc907E116054Ad103354f2D350FD2514433D57F6f", + MATIC: "0xAB594600376Ec9fD91F8e885dADF0CE036862dE0", + }, + band: "0xDA7a001b254CD22e46d3eAB04d937489c93174C3", + }, + [SupportedChain.ARBITRUM]: { + rpcEnvKey: "ARB_RPC_URL", + chainId: 42161, + chainlink: { + ETH: "0x639Fe6ab55C921f74e7fac1ee960C0B6293ba612", + BTC: "0x6ce185960375d0D8B66bEBDAf4c8e1b0ed0A4728", + }, + }, + [SupportedChain.OPTIMISM]: { + rpcEnvKey: "OPT_RPC_URL", + chainId: 10, + chainlink: { + ETH: "0x13e3Ee699D1909E989722E753853AE30b17e08c5", + BTC: "0xD702DD976Fb76Fffc2D3963D037dfDae5b04E593", + }, + }, + [SupportedChain.AVALANCHE]: { + rpcEnvKey: "AVAX_RPC_URL", + chainId: 43114, + chainlink: { + AVAX: "0x0A77230d17318075983913bC2145DB16C7366156", + ETH: "0x976B3D034E162d8bD72D6b9C989d545b839003b0", + BTC: "0x2779D32d5166BAaa2B2b658333bA7e6Ec0C65743", + }, + }, +}; + +/** 30-minute TWAP window */ +const TWAP_SECONDS = 1800; + +@Injectable() +export class PriceFeedService { + private readonly logger = new Logger(PriceFeedService.name); + private readonly providers = new Map(); + + constructor( + @InjectRepository(PriceRecord) + private readonly priceRecordRepository: Repository, + private readonly configService: ConfigService, + private readonly eventEmitter: EventEmitter2, + ) { + this.initProviders(); + } + + private initProviders(): void { + for (const [chain, cfg] of Object.entries(CHAIN_CONTRACTS)) { + const url = this.configService.get(cfg.rpcEnvKey); + if (url) { + this.providers.set(chain as SupportedChain, new JsonRpcProvider(url)); + } + } + } + + /** + * Fetch the current price for an asset on a chain, aggregate from all + * available sources, compute median, persist, and alert on deviation. + */ + async getCurrentPrice( + asset: string, + chain: SupportedChain, + ): Promise { + const prices = await this.fetchAllSources(asset, chain); + + if (Object.keys(prices).length === 0) { + throw new Error( + `No price sources available for ${asset} on ${chain}. Configure RPC URLs.`, + ); + } + + const { median, maxDeviationPercent } = this.aggregatePrices(prices); + const deviationAlert = maxDeviationPercent > DEVIATION_THRESHOLD_PERCENT; + + if (deviationAlert) { + this.eventEmitter.emit("price.deviation", { + asset, + chain, + prices, + maxDeviationPercent, + }); + this.logger.warn( + `Price deviation alert: ${asset}/${chain} — max deviation ${maxDeviationPercent.toFixed(2)}%`, + ); + } + + const record = this.priceRecordRepository.create({ + asset: asset.toUpperCase(), + chain, + price: median, + sourcePrices: prices as Record, + deviationAlert, + maxDeviationPercent, + }); + + const saved = await this.priceRecordRepository.save(record); + return this.toResponseDto(saved); + } + + /** + * Return stored historical prices for an asset/chain pair. + */ + async getHistoricalPrices( + asset: string, + chain: SupportedChain, + limit = 100, + ): Promise { + const records = await this.priceRecordRepository.find({ + where: { asset: asset.toUpperCase(), chain }, + order: { createdAt: "DESC" }, + take: limit, + }); + return records.map((r) => this.toResponseDto(r)); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private async fetchAllSources( + asset: string, + chain: SupportedChain, + ): Promise>> { + const provider = this.providers.get(chain); + const cfg = CHAIN_CONTRACTS[chain]; + const prices: Partial> = {}; + + if (!provider) { + return prices; + } + + const ticker = asset.toUpperCase(); + + await Promise.allSettled([ + // Chainlink + (async () => { + const feedAddress = cfg.chainlink?.[ticker]; + if (!feedAddress) return; + const feed = new Contract(feedAddress, CHAINLINK_ABI, provider); + const [roundData, decimals] = await Promise.all([ + feed.latestRoundData(), + feed.decimals(), + ]); + prices[PriceSource.CHAINLINK] = + Number(roundData.answer) / 10 ** Number(decimals); + })(), + + // Band Protocol + (async () => { + if (!cfg.band) return; + const ref = new Contract(cfg.band, BAND_ABI, provider); + const data = await ref.getReferenceData(ticker, "USD"); + // Band returns rate with 18 decimals + prices[PriceSource.BAND] = Number(data.rate) / 1e18; + })(), + + // Uniswap V3 TWAP + (async () => { + const poolAddress = cfg.uniswapV3Pools?.[ticker]; + if (!poolAddress) return; + const pool = new Contract(poolAddress, UNISWAP_V3_POOL_ABI, provider); + const [token0, observations] = await Promise.all([ + pool.token0(), + pool.observe([TWAP_SECONDS, 0]), + ]); + const [tickCumulativeOld, tickCumulativeNew] = + observations.tickCumulatives; + const avgTick = + (Number(tickCumulativeNew) - Number(tickCumulativeOld)) / + TWAP_SECONDS; + // tick → price: price = 1.0001^tick + // token0 is USDC on ETH/USDC pool, so price is inverted + const rawPrice = Math.pow(1.0001, avgTick); + // USDC has 6 decimals, WETH has 18 — adjust + const adjustedPrice = (1 / rawPrice) * 1e12; + prices[PriceSource.UNISWAP_TWAP] = adjustedPrice; + void token0; // used for context, suppress lint + })(), + ]); + + return prices; + } + + /** + * Compute median and max pairwise deviation from a set of prices. + */ + aggregatePrices(prices: Partial>): { + median: number; + maxDeviationPercent: number; + } { + const values = Object.values(prices).filter( + (v): v is number => typeof v === "number" && isFinite(v) && v > 0, + ); + + if (values.length === 0) { + return { median: 0, maxDeviationPercent: 0 }; + } + + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; + + let maxDeviationPercent = 0; + for (let i = 0; i < values.length; i++) { + for (let j = i + 1; j < values.length; j++) { + const avg = (values[i] + values[j]) / 2; + const dev = avg > 0 ? (Math.abs(values[i] - values[j]) / avg) * 100 : 0; + if (dev > maxDeviationPercent) maxDeviationPercent = dev; + } + } + + return { median, maxDeviationPercent }; + } + + private toResponseDto(record: PriceRecord): PriceResponseDto { + return { + asset: record.asset, + chain: record.chain, + price: Number(record.price), + sourcePrices: record.sourcePrices, + deviationAlert: record.deviationAlert, + maxDeviationPercent: Number(record.maxDeviationPercent), + timestamp: record.createdAt, + }; + } +} diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index f09e447..3e3557c 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -109,6 +109,11 @@ export class EnvironmentVariables { @IsNotEmpty() OPT_RPC_URL?: string; + @IsOptional() + @IsString() + @IsNotEmpty() + BSC_RPC_URL?: string; + // Oracle configuration @IsOptional() @IsString()