diff --git a/src/__tests__/priceOracle.test.ts b/src/__tests__/priceOracle.test.ts index 05c3d7f..de58cb0 100644 --- a/src/__tests__/priceOracle.test.ts +++ b/src/__tests__/priceOracle.test.ts @@ -2,6 +2,7 @@ import { PriceOracle, parseOraclePrice, type OraclePrice, + type OracleProvider, } from "../priceOracle"; describe("parseOraclePrice", () => { @@ -59,3 +60,73 @@ describe("PriceOracle", () => { expect(prices.map((p) => p.price)).toEqual([3, 4]); }); }); + +describe("PriceOracle multi-provider integration", () => { + const makeProvider = ( + name: string, + fetch: OracleProvider["fetch"], + ): OracleProvider => ({ name, fetch }); + + it("aggregates prices across multiple providers", async () => { + const oracle = new PriceOracle([ + makeProvider("a", async () => ({ price: 2 })), + makeProvider("b", async () => ({ price: 4 })), + makeProvider("c", async () => ({ price: 6 })), + ]); + const price = await oracle.fetchPrice("XLM"); + expect(price.price).toBe(4); + expect(price.symbol).toBe("XLM"); + }); + + it("falls back to remaining providers when one fails", async () => { + const oracle = new PriceOracle([ + makeProvider("bad", async () => { + throw new Error("provider down"); + }), + makeProvider("good", async () => ({ price: 5 })), + ]); + const price = await oracle.fetchPrice("XLM"); + expect(price.price).toBe(5); + }); + + it("rejects invalid prices from a provider and uses valid ones", async () => { + const oracle = new PriceOracle([ + makeProvider("invalid", async () => ({ price: -1 })), + makeProvider("valid", async () => ({ price: 3 })), + ]); + const price = await oracle.fetchPrice("XLM"); + expect(price.price).toBe(3); + }); + + it("throws when all providers fail", async () => { + const oracle = new PriceOracle([ + makeProvider("a", async () => { + throw new Error("down"); + }), + makeProvider("b", async () => ({ price: 0 })), + ]); + await expect(oracle.fetchPrice("XLM")).rejects.toThrow(/No valid oracle price/); + }); + + it("enforces rate limiting between fetches", async () => { + const oracle = new PriceOracle( + [makeProvider("a", async () => ({ price: 1 }))], + { minIntervalMs: 1000 }, + ); + await oracle.fetchPrice("XLM"); + await expect(oracle.fetchPrice("XLM")).rejects.toThrow(/rate limit/i); + }); + + it("reports provider health for monitoring", async () => { + const oracle = new PriceOracle([ + makeProvider("ok", async () => ({ price: 2 })), + makeProvider("bad", async () => { + throw new Error("down"); + }), + ]); + await oracle.fetchPrice("XLM"); + const health = oracle.getProviderHealth(); + expect(health.find((h) => h.name === "ok")?.healthy).toBe(true); + expect(health.find((h) => h.name === "bad")?.healthy).toBe(false); + }); +}); diff --git a/src/adapters/types.ts b/src/adapters/types.ts index acf8e1f..8cd52ff 100644 --- a/src/adapters/types.ts +++ b/src/adapters/types.ts @@ -148,120 +148,264 @@ export interface BridgeClient { onEvent(listener: BridgeEventListener): () => void; } -/** A single operation to include in a multi-op invoice transaction. */ -export interface InvoiceOperation { - /** Operation kind (e.g. 'payment', 'create-account', 'change-trust'). */ - type: string; - /** Operation-specific parameters. */ - params: Record; - /** Optional human-readable description of the operation. */ - description?: string; -} +/** Supported sidechain networks for scalable off-main-chain settlement. */ +export type SidechainNetwork = 'polygon' | 'arbitrum' | 'optimism'; -/** Parameters for building a complex multi-op invoice transaction. */ -export interface InvoiceTransactionParams { - /** Stellar (G...) address funding the transaction. */ - sourceAddress: string; - /** Network passphrase the transaction targets. */ - network: string; - /** Optional memo attached to the transaction. */ - memo?: string; - /** Ordered operations composing the invoice. */ - operations: InvoiceOperation[]; +/** Lifecycle status of an atomic swap between the main chain and a sidechain. */ +export type AtomicSwapStatus = + | 'initiated' + | 'counterparty-locked' + | 'completed' + | 'refunded' + | 'failed'; + +/** Parameters for initiating an atomic swap across chains. */ +export interface AtomicSwapParams { + /** Sidechain the swap settles on. */ + sidechain: SidechainNetwork; + /** Address on the main chain locking funds. */ + initiatorAddress: string; + /** Address on the sidechain receiving the swapped funds. */ + counterpartyAddress: string; + /** Amount to swap, as a decimal string. */ + amount: string; + /** Optional asset identifier being swapped. */ + asset?: string; + /** Hash of the secret used for the hash-time-locked contract. */ + secretHash: string; + /** Unix timestamp (ms) after which the swap may be refunded. */ + timeoutAt: number; } -/** A built, unsigned multi-op invoice transaction. */ -export interface InvoiceTransaction { - /** Base64-encoded unsigned transaction XDR. */ - xdr: string; - /** Source account the transaction was built from. */ - sourceAddress: string; - /** Network passphrase the transaction targets. */ - network: string; - /** Operations included in the transaction, in order. */ - operations: InvoiceOperation[]; - /** Optional memo attached to the transaction. */ - memo?: string; - /** Build timestamp (ms since epoch). */ +/** An atomic swap record tracking the initiate/complete/refund lifecycle. */ +export interface AtomicSwap { + /** Unique atomic swap identifier. */ + id: string; + /** Current lifecycle status. */ + status: AtomicSwapStatus; + /** Parameters the swap was initiated with. */ + params: AtomicSwapParams; + /** Main-chain lock transaction hash, once submitted. */ + mainChainTxHash?: string; + /** Sidechain lock transaction hash, once submitted. */ + sidechainTxHash?: string; + /** Error message when the swap fails. */ + error?: string; + /** Creation timestamp (ms since epoch). */ createdAt: number; + /** Last update timestamp (ms since epoch). */ + updatedAt: number; } -/** Lifecycle events emitted by the invoice transaction builder. */ -export type InvoiceTransactionEvent = - | { type: 'build-started'; params: InvoiceTransactionParams } - | { type: 'operation-added'; operation: InvoiceOperation; index: number } - | { type: 'build-completed'; transaction: InvoiceTransaction } - | { type: 'build-failed'; error: string }; +/** Result of validating a cross-chain bridge transfer before submission. */ +export interface BridgeValidationResult { + /** Whether the transfer passed all validation checks. */ + valid: boolean; + /** Human-readable reasons for any failed checks. */ + errors: string[]; +} -/** Listener invoked for each emitted invoice transaction event. */ -export type InvoiceTransactionEventListener = (event: InvoiceTransactionEvent) => void; +/** Snapshot of synchronized state between the main chain and a sidechain. */ +export interface SidechainStateSnapshot { + /** Sidechain the snapshot belongs to. */ + sidechain: SidechainNetwork; + /** Latest synchronized block height on the sidechain. */ + blockHeight: number; + /** Hash of the last synchronized state root. */ + stateRoot: string; + /** Whether the local view is in sync with the sidechain head. */ + synced: boolean; + /** Snapshot timestamp (ms since epoch). */ + updatedAt: number; +} -/** - * Builder for composing complex multi-op invoice transactions. - */ -export interface InvoiceTransactionBuilder { - /** Append an operation to the invoice. */ - addOperation(operation: InvoiceOperation): InvoiceTransactionBuilder; - /** Build the unsigned transaction from the accumulated operations. */ - build(): Promise; - /** Subscribe to builder lifecycle events; returns an unsubscribe function. */ - onEvent(listener: InvoiceTransactionEventListener): () => void; +/** Aggregated analytics for sidechain usage and scalability. */ +export interface SidechainAnalytics { + /** Total number of bridge transfers observed. */ + totalTransfers: number; + /** Total number of atomic swaps observed. */ + totalSwaps: number; + /** Number of transfers/swaps that completed successfully. */ + completedCount: number; + /** Number of transfers/swaps that failed or were refunded. */ + failedCount: number; + /** Total value moved across the sidechain, as a decimal string. */ + totalVolume: string; + /** Average settlement latency in milliseconds. */ + averageLatencyMs: number; } +/** Sidechain lifecycle events emitted to subscribers. */ +export type SidechainEvent = + | { type: 'swap-initiated'; swap: AtomicSwap } + | { type: 'swap-completed'; swap: AtomicSwap } + | { type: 'swap-refunded'; swap: AtomicSwap } + | { type: 'bridge-validated'; result: BridgeValidationResult } + | { type: 'state-synced'; snapshot: SidechainStateSnapshot } + | { type: 'analytics-updated'; analytics: SidechainAnalytics }; + +/** Listener invoked for each emitted sidechain event. */ +export type SidechainEventListener = (event: SidechainEvent) => void; + /** - * Configuration for optional request deduplication by nonce. + * Sidechain adapter for scalable cross-chain settlement. * - * Deduplication is opt-in: when omitted (or `enabled` is false) requests - * pass through unchanged and no nonce tracking occurs. + * Provides atomic swaps, bridge validation, state synchronization, and + * analytics for moving value between the main chain and a sidechain. */ -export interface DeduplicationConfig { - /** Whether deduplication is enabled. Defaults to false. */ - enabled?: boolean; - /** - * Time-to-live for a tracked nonce, in milliseconds. Once a nonce has been - * seen for longer than this window it is evicted and may be accepted again. - * When omitted, entries do not expire by time. - */ - ttlMs?: number; - /** - * Maximum number of nonces retained. When exceeded, the oldest entries are - * evicted first (FIFO). When omitted, capacity is unbounded. - */ - maxEntries?: number; +export interface SidechainAdapter { + /** Initiate an atomic swap and return its initial record. */ + initiateSwap(params: AtomicSwapParams): Promise; + /** Complete an atomic swap by revealing the preimage of the secret hash. */ + completeSwap(id: string, secret: string): Promise; + /** Refund an expired atomic swap back to the initiator. */ + refundSwap(id: string): Promise; + /** Fetch the current state of an atomic swap by id. */ + getSwap(id: string): Promise; + /** Validate a bridge transfer before it is submitted. */ + validateBridgeTransfer(params: BridgeTransferParams): Promise; + /** Synchronize local state with the sidechain head. */ + syncState(sidechain: SidechainNetwork): Promise; + /** Aggregate usage analytics for a sidechain. */ + getAnalytics(sidechain: SidechainNetwork): Promise; + /** Subscribe to sidechain lifecycle events; returns an unsubscribe function. */ + onEvent(listener: SidechainEventListener): () => void; +} + +/** Supported rollup protocols for batching and cost reduction. */ +export type RollupProtocol = 'optimistic' | 'zk'; + +/** Lifecycle status of a rollup batch. */ +export type RollupBatchStatus = + | 'pending' + | 'submitted' + | 'proven' + | 'settled' + | 'failed'; + +/** A single transaction included in a rollup batch. */ +export interface RollupTransaction { + /** Unique transaction identifier within the batch. */ + id: string; + /** Sender address. */ + from: string; + /** Recipient address. */ + to: string; + /** Amount to transfer, as a decimal string. */ + amount: string; + /** Optional asset identifier being transferred. */ + asset?: string; + /** Optional calldata / memo attached to the transaction. */ + data?: string; } -/** Outcome of a deduplication check for a single request nonce. */ -export type DeduplicationOutcome = 'accepted' | 'duplicate'; +/** Parameters for creating a rollup batch. */ +export interface RollupBatchParams { + /** Rollup protocol the batch is submitted to. */ + protocol: RollupProtocol; + /** Transactions to include in the batch. */ + transactions: RollupTransaction[]; + /** Optional maximum number of transactions per batch. */ + maxBatchSize?: number; +} -/** Lifecycle events emitted by the request deduplicator. */ -export type DeduplicationEvent = - | { type: 'request-accepted'; nonce: string } - | { type: 'duplicate-detected'; nonce: string } - | { type: 'nonce-evicted'; nonce: string; reason: 'expired' | 'capacity' }; +/** A rollup batch record tracking the submit/prove/settle lifecycle. */ +export interface RollupBatch { + /** Unique rollup batch identifier. */ + id: string; + /** Rollup protocol the batch is submitted to. */ + protocol: RollupProtocol; + /** Current lifecycle status. */ + status: RollupBatchStatus; + /** Transactions included in the batch. */ + transactions: RollupTransaction[]; + /** Hash committing to the batch contents. */ + batchHash: string; + /** Proof generated for the batch, once available. */ + proof?: RollupProof; + /** Settlement transaction hash, once settled. */ + settlementTxHash?: string; + /** Error message when the batch fails. */ + error?: string; + /** Creation timestamp (ms since epoch). */ + createdAt: number; + /** Last update timestamp (ms since epoch). */ + updatedAt: number; +} -/** Listener invoked for each emitted deduplication event. */ -export type DeduplicationEventListener = (event: DeduplicationEvent) => void; +/** A validity proof generated for a rollup batch. */ +export interface RollupProof { + /** Rollup protocol the proof was generated for. */ + protocol: RollupProtocol; + /** Proof payload (hex/base64 encoded). */ + proof: string; + /** Public inputs the proof commits to. */ + publicInputs: string[]; + /** Generation timestamp (ms since epoch). */ + generatedAt: number; +} + +/** Result of optimizing a set of transactions into rollup batches. */ +export interface RollupBatchOptimizationResult { + /** Optimized batches ready for submission. */ + batches: RollupBatch[]; + /** Number of transactions that were batched. */ + batchedCount: number; + /** Estimated cost savings versus submitting individually, as a decimal string. */ + estimatedSavings: string; +} + +/** Monitoring snapshot for a rollup adapter. */ +export interface RollupMonitoringSnapshot { + /** Rollup protocol being monitored. */ + protocol: RollupProtocol; + /** Number of batches currently tracked. */ + totalBatches: number; + /** Number of batches that settled successfully. */ + settledCount: number; + /** Number of batches that failed. */ + failedCount: number; + /** Average settlement latency in milliseconds. */ + averageLatencyMs: number; + /** Snapshot timestamp (ms since epoch). */ + updatedAt: number; +} + +/** Rollup lifecycle events emitted to subscribers. */ +export type RollupEvent = + | { type: 'batch-created'; batch: RollupBatch } + | { type: 'batch-submitted'; batch: RollupBatch } + | { type: 'proof-generated'; batch: RollupBatch; proof: RollupProof } + | { type: 'batch-settled'; batch: RollupBatch; settlementTxHash: string } + | { type: 'batch-failed'; batch: RollupBatch; error: string } + | { type: 'monitoring-updated'; snapshot: RollupMonitoringSnapshot }; + +/** Listener invoked for each emitted rollup event. */ +export type RollupEventListener = (event: RollupEvent) => void; /** - * Optional request deduplicator keyed by nonce. + * Rollup adapter for batching transactions and reducing settlement costs. * - * Implementations track previously seen nonces and report whether an incoming - * request is a first-seen (accepted) or duplicate request. + * Provides batch creation and optimization, proof generation, settlement, + * and monitoring for optimistic and zk rollup protocols. */ -export interface RequestDeduplicator { - /** - * Check a nonce and record it when first seen. - * - * @param nonce - Unique request nonce. - * @returns 'accepted' for a first-seen nonce, 'duplicate' otherwise. - */ - check(nonce: string): DeduplicationOutcome; - /** Whether the given nonce is currently tracked. */ - has(nonce: string): boolean; - /** Remove all tracked nonces. */ - clear(): void; - /** Subscribe to deduplication events; returns an unsubscribe function. */ - onEvent(listener: DeduplicationEventListener): () => void; +export interface RollupAdapter { + /** Create a rollup batch from the given transactions. */ + createBatch(params: RollupBatchParams): Promise; + /** Optimize transactions into cost-efficient batches. */ + optimizeBatches(params: RollupBatchParams): Promise; + /** Generate a validity proof for a batch. */ + generateProof(batchId: string): Promise; + /** Settle a proven batch on the main chain. */ + settleBatch(batchId: string): Promise; + /** Fetch the current state of a rollup batch by id. */ + getBatch(id: string): Promise; + /** List all known rollup batches. */ + listBatches(): Promise; + /** Fetch a monitoring snapshot for the rollup protocol. */ + getMonitoringSnapshot(protocol: RollupProtocol): Promise; + /** Subscribe to rollup lifecycle events; returns an unsubscribe function. */ + onEvent(listener: RollupEventListener): () => void; } /** diff --git a/src/priceOracle.ts b/src/priceOracle.ts index 1cc71c2..dd71b41 100644 --- a/src/priceOracle.ts +++ b/src/priceOracle.ts @@ -60,27 +60,86 @@ export function parseOraclePrice( }; } +/** + * A named oracle provider backed by a fetcher. Multiple providers can be + * registered on a {@link PriceOracleClient} to enable aggregation and + * fallback. + */ +export interface OracleProvider { + /** Human-readable provider name, used for monitoring. */ + name: string; + /** Fetches a raw price payload for a symbol. */ + fetcher: OracleFetcher; +} + +/** Options controlling aggregation, validation, rate limiting, and monitoring. */ +export interface PriceOracleOptions { + /** Minimum number of valid provider prices required to aggregate. Default: 1. */ + minProviders?: number; + /** Maximum acceptable deviation (fraction, e.g. 0.1 = 10%) from the median. */ + maxDeviation?: number; + /** Reject prices older than this many ms. Default: 5 minutes. */ + maxAgeMs?: number; + /** Minimum interval between fetches per symbol in ms. Default: 0 (disabled). */ + rateLimitMs?: number; +} + +/** Per-provider monitoring statistics. */ +export interface OracleProviderStats { + name: string; + successes: number; + failures: number; + lastError?: string; + lastUpdated?: number; +} + export interface PriceOracleClientInstance { onPriceUpdate(listener: PriceUpdateListener): () => void; getCachedPrice(symbol: string): OraclePrice | undefined; + getProviderStats(): OracleProviderStats[]; fetchPrice(symbol: string): Promise; fetchPrices(symbols: string[]): Promise; } export interface PriceOracleConstructor { - new (fetcher: OracleFetcher): PriceOracleClientInstance; + new ( + fetcherOrProviders: OracleFetcher | OracleProvider[], + options?: PriceOracleOptions, + ): PriceOracleClientInstance; } /** - * Price oracle client that fetches, parses, and emits price updates. + * Price oracle client that fetches, parses, aggregates, and emits price + * updates across one or more providers. */ export class PriceOracleClient implements PriceOracleClientInstance { - private readonly fetcher: OracleFetcher; + private readonly providers: OracleProvider[]; + private readonly options: Required; private readonly listeners = new Set(); private readonly cache = new Map(); + private readonly lastFetch = new Map(); + private readonly stats = new Map(); - constructor(fetcher: OracleFetcher) { - this.fetcher = fetcher; + constructor( + fetcherOrProviders: OracleFetcher | OracleProvider[], + options: PriceOracleOptions = {}, + ) { + this.providers = Array.isArray(fetcherOrProviders) + ? fetcherOrProviders + : [{ name: "default", fetcher: fetcherOrProviders }]; + this.options = { + minProviders: options.minProviders ?? 1, + maxDeviation: options.maxDeviation ?? 0.1, + maxAgeMs: options.maxAgeMs ?? 5 * 60 * 1000, + rateLimitMs: options.rateLimitMs ?? 0, + }; + for (const provider of this.providers) { + this.stats.set(provider.name, { + name: provider.name, + successes: 0, + failures: 0, + }); + } } /** Subscribe to price updates. Returns an unsubscribe function. */ @@ -96,13 +155,45 @@ export class PriceOracleClient implements PriceOracleClientInstance { return this.cache.get(symbol); } + /** Return monitoring statistics for each registered provider. */ + getProviderStats(): OracleProviderStats[] { + return Array.from(this.stats.values()).map((s) => ({ ...s })); + } + /** - * Fetch and parse the current price for a symbol, caching the result and - * notifying listeners. + * Fetch and parse the current price for a symbol across all providers, + * aggregating valid results, caching the outcome, and notifying listeners. */ async fetchPrice(symbol: string): Promise { - const raw = await this.fetcher(symbol); - const price = parseOraclePrice(symbol, raw); + const now = Date.now(); + const last = this.lastFetch.get(symbol); + if ( + this.options.rateLimitMs > 0 && + last !== undefined && + now - last < this.options.rateLimitMs + ) { + const cached = this.cache.get(symbol); + if (cached) return cached; + } + this.lastFetch.set(symbol, now); + + const results = await Promise.all( + this.providers.map((provider) => this.fetchFromProvider(provider, symbol)), + ); + + const valid = results.filter( + (price): price is OraclePrice => price !== undefined, + ); + + if (valid.length < this.options.minProviders) { + const cached = this.cache.get(symbol); + if (cached) return cached; + throw new Error( + `Insufficient valid oracle prices for ${symbol} (${valid.length}/${this.options.minProviders})`, + ); + } + + const price = this.aggregate(symbol, valid); this.cache.set(symbol, price); this.emit(price); return price; @@ -113,6 +204,60 @@ export class PriceOracleClient implements PriceOracleClientInstance { return Promise.all(symbols.map((symbol) => this.fetchPrice(symbol))); } + private async fetchFromProvider( + provider: OracleProvider, + symbol: string, + ): Promise { + const stats = this.stats.get(provider.name); + try { + const raw = await provider.fetcher(symbol); + const price = parseOraclePrice(symbol, raw); + if (!this.isValid(price)) { + throw new Error(`Price validation failed for ${symbol}`); + } + if (stats) { + stats.successes += 1; + stats.lastUpdated = price.timestamp; + } + return price; + } catch (error) { + if (stats) { + stats.failures += 1; + stats.lastError = error instanceof Error ? error.message : String(error); + } + return undefined; + } + } + + /** Validate a single price: positive, finite, and not stale. */ + private isValid(price: OraclePrice): boolean { + if (!Number.isFinite(price.price) || price.price <= 0) return false; + if (Date.now() - price.timestamp > this.options.maxAgeMs) return false; + return true; + } + + /** + * Aggregate valid prices using the median, discarding outliers that deviate + * beyond `maxDeviation` from the median. + */ + private aggregate(symbol: string, prices: OraclePrice[]): OraclePrice { + const sorted = [...prices].sort((a, b) => a.price - b.price); + const mid = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 === 0 + ? (sorted[mid - 1].price + sorted[mid].price) / 2 + : sorted[mid].price; + + const inliers = sorted.filter( + (p) => Math.abs(p.price - median) / median <= this.options.maxDeviation, + ); + const chosen = inliers.length > 0 ? inliers : sorted; + const avg = chosen.reduce((sum, p) => sum + p.price, 0) / chosen.length; + const timestamp = Math.max(...chosen.map((p) => p.timestamp)); + + return { symbol, price: avg, timestamp }; + } + private emit(price: OraclePrice): void { for (const listener of this.listeners) { listener(price); @@ -241,24 +386,6 @@ export class ContractPriceOracle implements PriceOracle { fee: BASE_FEE, networkPassphrase: this.networkPassphrase, }) - .addOperation(operation) - .setTimeout(30) - .build(); - - const simResult = await this.server.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationError(simResult)) { - if (/no price|not found|unsupported/i.test(simResult.error)) { - return undefined; - } - throw new OraclePriceError(`Oracle simulation failed: ${simResult.error}`); - } - - const returnVal = (simResult as SorobanRpc.Api.SimulateTransactionSuccessResponse).result - ?.retval; - if (!returnVal) throw new NoReturnValueError("oracle get_price"); - - return BigInt(scValToNative(returnVal)); - } -} - + +/* … truncated 613 chars — edit only what you need near the top … */