diff --git a/package-lock.json b/package-lock.json index 2656bc30..94d56c5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -250,7 +250,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2360,7 +2359,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2531,7 +2529,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.14.tgz", "integrity": "sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -2579,7 +2576,6 @@ "integrity": "sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2653,7 +2649,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.14.tgz", "integrity": "sha512-Fs+/j+mBSBSXErOQJ/YdUn/HqJGSJ4pGfiJyYOyz04l42uNVnqEakvu1kXLbxMabR6vd6/h9d6Bi4tso9p7o4Q==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -3005,6 +3000,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3025,6 +3021,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -3038,6 +3035,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -3052,7 +3050,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -3455,7 +3454,6 @@ "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3485,7 +3483,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3626,7 +3623,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -4065,7 +4061,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4137,7 +4132,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4380,7 +4374,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -4620,7 +4613,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4836,7 +4828,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4883,15 +4874,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -5817,7 +5806,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5878,7 +5866,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6150,7 +6137,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7605,7 +7591,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -10044,7 +10029,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -10302,7 +10286,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10485,7 +10468,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10498,7 +10480,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -10559,8 +10540,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", @@ -10726,7 +10706,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -11552,7 +11531,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11949,7 +11927,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12116,7 +12093,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -12312,7 +12288,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12671,6 +12646,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -12689,6 +12665,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -12702,6 +12679,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -12716,6 +12694,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -12725,7 +12704,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/schema-utils": { "version": "4.3.3", @@ -12733,6 +12713,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/src/history/routes/stellar/index.ts b/src/history/routes/stellar/index.ts new file mode 100644 index 00000000..ae1d6602 --- /dev/null +++ b/src/history/routes/stellar/index.ts @@ -0,0 +1,6 @@ +export * from './route-history'; +export * from './soroban-route-snapshot-store'; + +import { SorobanRouteSnapshotStore } from './soroban-route-snapshot-store'; + +export const sorobanRouteSnapshotStore = new SorobanRouteSnapshotStore(); diff --git a/src/history/routes/stellar/soroban-route-snapshot-store.spec.ts b/src/history/routes/stellar/soroban-route-snapshot-store.spec.ts new file mode 100644 index 00000000..a49c81db --- /dev/null +++ b/src/history/routes/stellar/soroban-route-snapshot-store.spec.ts @@ -0,0 +1,256 @@ +import { + SorobanRouteSnapshotStore, + type SorobanRouteSnapshotInput, +} from './soroban-route-snapshot-store'; + +function input(overrides: Partial = {}): SorobanRouteSnapshotInput { + return { + routeId: 'XLM->USDC', + provider: 'soroban-provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 0.5, + estimatedDurationMs: 30_000, + capturedAt: new Date('2026-01-01T00:00:00Z'), + ...overrides, + }; +} + +describe('SorobanRouteSnapshotStore', () => { + describe('capture / getHistory', () => { + it('stores a route snapshot', () => { + const store = new SorobanRouteSnapshotStore(); + store.capture(input()); + + const history = store.getHistory('XLM->USDC'); + expect(history).toHaveLength(1); + expect(history[0].estimatedFee).toBe(0.5); + expect(history[0].estimatedDurationMs).toBe(30_000); + expect(history[0].provider).toBe('soroban-provider-a'); + }); + + it('defaults capturedAt to current time when omitted', () => { + const store = new SorobanRouteSnapshotStore(); + const before = Date.now(); + const snapshot = store.capture(input({ capturedAt: undefined })); + expect(snapshot.capturedAt.getTime()).toBeGreaterThanOrEqual(before); + }); + + it('preserves contractAddress when provided', () => { + const store = new SorobanRouteSnapshotStore(); + const snapshot = store.capture(input({ contractAddress: 'CABC123' })); + expect(snapshot.contractAddress).toBe('CABC123'); + }); + + it('keeps snapshots ordered chronologically even when inserted out of order', () => { + const store = new SorobanRouteSnapshotStore(); + store.capture(input({ capturedAt: new Date('2026-01-03T00:00:00Z') })); + store.capture(input({ capturedAt: new Date('2026-01-01T00:00:00Z') })); + store.capture(input({ capturedAt: new Date('2026-01-02T00:00:00Z') })); + + const times = store + .getHistory('XLM->USDC') + .map((s) => s.capturedAt.toISOString()); + expect(times).toEqual([ + '2026-01-01T00:00:00.000Z', + '2026-01-02T00:00:00.000Z', + '2026-01-03T00:00:00.000Z', + ]); + }); + + it('isolates snapshots per route', () => { + const store = new SorobanRouteSnapshotStore(); + store.capture(input({ routeId: 'A' })); + store.capture(input({ routeId: 'B' })); + store.capture(input({ routeId: 'B' })); + + expect(store.getHistory('A')).toHaveLength(1); + expect(store.getHistory('B')).toHaveLength(2); + expect(store.getTrackedRoutes().sort()).toEqual(['A', 'B']); + }); + + it('returns copies so callers cannot mutate stored snapshots', () => { + const store = new SorobanRouteSnapshotStore(); + store.capture(input()); + const history = store.getHistory('XLM->USDC'); + history[0].estimatedFee = 999; + expect(store.getHistory('XLM->USDC')[0].estimatedFee).toBe(0.5); + }); + + it('returns empty array for unknown route', () => { + const store = new SorobanRouteSnapshotStore(); + expect(store.getHistory('UNKNOWN')).toEqual([]); + }); + }); + + describe('historical queries', () => { + function buildMultiDay(): SorobanRouteSnapshotStore { + const store = new SorobanRouteSnapshotStore(); + for (let day = 1; day <= 5; day++) { + store.capture( + input({ capturedAt: new Date(`2026-01-0${day}T00:00:00Z`) }), + ); + } + return store; + } + + it('filters by date range', () => { + const store = buildMultiDay(); + const result = store.getHistory('XLM->USDC', { + from: new Date('2026-01-02T00:00:00Z'), + to: new Date('2026-01-04T00:00:00Z'), + }); + expect(result).toHaveLength(3); + }); + + it('limits to the most recent N within range', () => { + const store = buildMultiDay(); + const result = store.getHistory('XLM->USDC', { limit: 2 }); + expect(result.map((s) => s.capturedAt.toISOString())).toEqual([ + '2026-01-04T00:00:00.000Z', + '2026-01-05T00:00:00.000Z', + ]); + }); + + it('returns the latest snapshot', () => { + const store = buildMultiDay(); + expect(store.getLatest('XLM->USDC')?.capturedAt.toISOString()).toBe( + '2026-01-05T00:00:00.000Z', + ); + }); + + it('returns null for getLatest on unknown route', () => { + const store = new SorobanRouteSnapshotStore(); + expect(store.getLatest('UNKNOWN')).toBeNull(); + }); + }); + + describe('getTrend', () => { + it('returns null when there are no snapshots in range', () => { + const store = new SorobanRouteSnapshotStore(); + expect(store.getTrend('UNKNOWN')).toBeNull(); + }); + + it('aggregates fee and duration statistics', () => { + const store = new SorobanRouteSnapshotStore(); + store.capture(input({ estimatedFee: 0.2, estimatedDurationMs: 10_000, capturedAt: new Date('2026-01-01T00:00:00Z') })); + store.capture(input({ estimatedFee: 0.6, estimatedDurationMs: 50_000, capturedAt: new Date('2026-01-02T00:00:00Z') })); + store.capture(input({ estimatedFee: 1.0, estimatedDurationMs: 30_000, capturedAt: new Date('2026-01-03T00:00:00Z') })); + + const trend = store.getTrend('XLM->USDC'); + expect(trend).not.toBeNull(); + expect(trend!.sampleCount).toBe(3); + expect(trend!.averageFee).toBeCloseTo((0.2 + 0.6 + 1.0) / 3); + expect(trend!.minFee).toBe(0.2); + expect(trend!.maxFee).toBe(1.0); + expect(trend!.averageDurationMs).toBeCloseTo((10_000 + 50_000 + 30_000) / 3); + expect(trend!.minDurationMs).toBe(10_000); + expect(trend!.maxDurationMs).toBe(50_000); + }); + + it('reports trend window boundaries', () => { + const store = new SorobanRouteSnapshotStore(); + store.capture(input({ capturedAt: new Date('2026-01-01T00:00:00Z') })); + store.capture(input({ capturedAt: new Date('2026-01-03T00:00:00Z') })); + + const trend = store.getTrend('XLM->USDC'); + expect(trend!.from?.toISOString()).toBe('2026-01-01T00:00:00.000Z'); + expect(trend!.to?.toISOString()).toBe('2026-01-03T00:00:00.000Z'); + }); + + it('detects a rising fee trend', () => { + const store = new SorobanRouteSnapshotStore(); + [0.1, 0.2, 0.8, 1.0].forEach((fee, i) => + store.capture(input({ estimatedFee: fee, capturedAt: new Date(`2026-01-0${i + 1}T00:00:00Z`) })), + ); + expect(store.getTrend('XLM->USDC')!.feeTrend).toBe('rising'); + }); + + it('detects a falling fee trend', () => { + const store = new SorobanRouteSnapshotStore(); + [1.0, 0.8, 0.2, 0.1].forEach((fee, i) => + store.capture(input({ estimatedFee: fee, capturedAt: new Date(`2026-01-0${i + 1}T00:00:00Z`) })), + ); + expect(store.getTrend('XLM->USDC')!.feeTrend).toBe('falling'); + }); + + it('reports stable fee trend for flat values', () => { + const store = new SorobanRouteSnapshotStore(); + [0.5, 0.5, 0.5, 0.5].forEach((fee, i) => + store.capture(input({ estimatedFee: fee, capturedAt: new Date(`2026-01-0${i + 1}T00:00:00Z`) })), + ); + expect(store.getTrend('XLM->USDC')!.feeTrend).toBe('stable'); + }); + + it('detects a rising duration trend', () => { + const store = new SorobanRouteSnapshotStore(); + [5_000, 10_000, 40_000, 60_000].forEach((ms, i) => + store.capture(input({ estimatedDurationMs: ms, capturedAt: new Date(`2026-01-0${i + 1}T00:00:00Z`) })), + ); + expect(store.getTrend('XLM->USDC')!.durationTrend).toBe('rising'); + }); + + it('detects a falling duration trend', () => { + const store = new SorobanRouteSnapshotStore(); + [60_000, 40_000, 10_000, 5_000].forEach((ms, i) => + store.capture(input({ estimatedDurationMs: ms, capturedAt: new Date(`2026-01-0${i + 1}T00:00:00Z`) })), + ); + expect(store.getTrend('XLM->USDC')!.durationTrend).toBe('falling'); + }); + + it('respects query filters when computing trends', () => { + const store = new SorobanRouteSnapshotStore(); + store.capture(input({ estimatedFee: 5.0, capturedAt: new Date('2026-01-01T00:00:00Z') })); + store.capture(input({ estimatedFee: 0.5, capturedAt: new Date('2026-01-02T00:00:00Z') })); + store.capture(input({ estimatedFee: 0.5, capturedAt: new Date('2026-01-03T00:00:00Z') })); + + const trend = store.getTrend('XLM->USDC', { from: new Date('2026-01-02T00:00:00Z') }); + expect(trend!.sampleCount).toBe(2); + expect(trend!.averageFee).toBeCloseTo(0.5); + }); + }); + + describe('retention and eviction', () => { + it('evicts oldest snapshots beyond maxSnapshotsPerRoute', () => { + const store = new SorobanRouteSnapshotStore({ maxSnapshotsPerRoute: 3 }); + for (let i = 0; i < 5; i++) { + store.capture(input({ capturedAt: new Date(2026, 0, 1, 0, 0, i) })); + } + const history = store.getHistory('XLM->USDC'); + expect(history).toHaveLength(3); + expect(history[0].capturedAt.getSeconds()).toBe(2); + }); + + it('prunes snapshots older than the retention window on write', () => { + const store = new SorobanRouteSnapshotStore({ retentionMs: 60_000 }); + const now = Date.now(); + store.capture(input({ capturedAt: new Date(now - 120_000) })); + store.capture(input({ capturedAt: new Date(now) })); + expect(store.getHistory('XLM->USDC')).toHaveLength(1); + }); + }); + + describe('clearing', () => { + it('clears a single route', () => { + const store = new SorobanRouteSnapshotStore(); + store.capture(input({ routeId: 'A' })); + store.capture(input({ routeId: 'B' })); + expect(store.clearRoute('A')).toBe(true); + expect(store.getHistory('A')).toHaveLength(0); + expect(store.getHistory('B')).toHaveLength(1); + }); + + it('returns false when clearing a route with no snapshots', () => { + const store = new SorobanRouteSnapshotStore(); + expect(store.clearRoute('UNKNOWN')).toBe(false); + }); + + it('clears all routes', () => { + const store = new SorobanRouteSnapshotStore(); + store.capture(input({ routeId: 'A' })); + store.capture(input({ routeId: 'B' })); + store.clear(); + expect(store.getTrackedRoutes()).toHaveLength(0); + }); + }); +}); diff --git a/src/history/routes/stellar/soroban-route-snapshot-store.ts b/src/history/routes/stellar/soroban-route-snapshot-store.ts new file mode 100644 index 00000000..0964bcc8 --- /dev/null +++ b/src/history/routes/stellar/soroban-route-snapshot-store.ts @@ -0,0 +1,199 @@ +export interface SorobanRouteSnapshot { + routeId: string; + provider: string; + sourceChain: string; + destinationChain: string; + estimatedFee: number; + estimatedDurationMs: number; + contractAddress?: string; + capturedAt: Date; +} + +export interface SorobanRouteSnapshotInput { + routeId: string; + provider: string; + sourceChain: string; + destinationChain: string; + estimatedFee: number; + estimatedDurationMs: number; + contractAddress?: string; + /** Defaults to current time when omitted. */ + capturedAt?: Date; +} + +export interface SorobanSnapshotQuery { + from?: Date; + to?: Date; + /** Return only the most recent N results within the time range. */ + limit?: number; +} + +export interface SorobanRouteSnapshotStoreConfig { + /** Maximum snapshots retained per route. Oldest are evicted first. Default: 1000. */ + maxSnapshotsPerRoute?: number; + /** Snapshots older than this (ms) are pruned on write. 0 disables pruning. Default: 0. */ + retentionMs?: number; +} + +export interface SorobanRouteSnapshotTrend { + routeId: string; + sampleCount: number; + from?: Date; + to?: Date; + averageFee: number; + minFee: number; + maxFee: number; + averageDurationMs: number; + minDurationMs: number; + maxDurationMs: number; + feeTrend: 'rising' | 'falling' | 'stable'; + durationTrend: 'rising' | 'falling' | 'stable'; +} + +export class SorobanRouteSnapshotStore { + private readonly config: Required; + private readonly snapshots = new Map(); + + constructor(config: SorobanRouteSnapshotStoreConfig = {}) { + this.config = { + maxSnapshotsPerRoute: config.maxSnapshotsPerRoute ?? 1000, + retentionMs: config.retentionMs ?? 0, + }; + } + + /** Records a route snapshot, evicting stale entries according to store config. */ + capture(input: SorobanRouteSnapshotInput): SorobanRouteSnapshot { + const snapshot: SorobanRouteSnapshot = { + routeId: input.routeId, + provider: input.provider, + sourceChain: input.sourceChain, + destinationChain: input.destinationChain, + estimatedFee: input.estimatedFee, + estimatedDurationMs: input.estimatedDurationMs, + contractAddress: input.contractAddress, + capturedAt: input.capturedAt ?? new Date(), + }; + + const series = this.snapshots.get(input.routeId) ?? []; + series.push(snapshot); + series.sort((a, b) => a.capturedAt.getTime() - b.capturedAt.getTime()); + this.prune(series); + this.snapshots.set(input.routeId, series); + + return snapshot; + } + + /** Returns stored snapshots for a route, optionally filtered by query. */ + getHistory(routeId: string, query: SorobanSnapshotQuery = {}): SorobanRouteSnapshot[] { + const series = this.snapshots.get(routeId) ?? []; + let result = series.filter((s) => withinRange(s.capturedAt, query)); + if (query.limit !== undefined && query.limit >= 0) { + result = result.slice(-query.limit); + } + return result.map((s) => ({ ...s })); + } + + /** Returns the most recent snapshot for a route, or null if none exists. */ + getLatest(routeId: string): SorobanRouteSnapshot | null { + const series = this.snapshots.get(routeId); + if (!series || series.length === 0) return null; + return { ...series[series.length - 1] }; + } + + /** Lists all route IDs that have at least one recorded snapshot. */ + getTrackedRoutes(): string[] { + return Array.from(this.snapshots.keys()); + } + + /** + * Computes aggregated fee and duration trends for a route over a query window. + * Returns null when no snapshots are found in range. + */ + getTrend(routeId: string, query: SorobanSnapshotQuery = {}): SorobanRouteSnapshotTrend | null { + const series = this.getHistory(routeId, query); + if (series.length === 0) return null; + + let feeSum = 0; + let minFee = Number.POSITIVE_INFINITY; + let maxFee = Number.NEGATIVE_INFINITY; + let durationSum = 0; + let minDurationMs = Number.POSITIVE_INFINITY; + let maxDurationMs = Number.NEGATIVE_INFINITY; + + for (const s of series) { + feeSum += s.estimatedFee; + minFee = Math.min(minFee, s.estimatedFee); + maxFee = Math.max(maxFee, s.estimatedFee); + durationSum += s.estimatedDurationMs; + minDurationMs = Math.min(minDurationMs, s.estimatedDurationMs); + maxDurationMs = Math.max(maxDurationMs, s.estimatedDurationMs); + } + + return { + routeId, + sampleCount: series.length, + from: series[0].capturedAt, + to: series[series.length - 1].capturedAt, + averageFee: feeSum / series.length, + minFee, + maxFee, + averageDurationMs: durationSum / series.length, + minDurationMs, + maxDurationMs, + feeTrend: computeTrend(series.map((s) => s.estimatedFee)), + durationTrend: computeTrend(series.map((s) => s.estimatedDurationMs)), + }; + } + + /** Removes all snapshots for a single route. Returns true if any were removed. */ + clearRoute(routeId: string): boolean { + return this.snapshots.delete(routeId); + } + + /** Removes all snapshots across every route. */ + clear(): void { + this.snapshots.clear(); + } + + private prune(series: SorobanRouteSnapshot[]): void { + if (this.config.retentionMs > 0) { + const cutoff = Date.now() - this.config.retentionMs; + let stale = 0; + while (stale < series.length && series[stale].capturedAt.getTime() < cutoff) { + stale += 1; + } + if (stale > 0) series.splice(0, stale); + } + + const overflow = series.length - this.config.maxSnapshotsPerRoute; + if (overflow > 0) series.splice(0, overflow); + } +} + +function withinRange(capturedAt: Date, query: SorobanSnapshotQuery): boolean { + if (query.from && capturedAt < query.from) return false; + if (query.to && capturedAt > query.to) return false; + return true; +} + +function computeTrend(values: number[]): 'rising' | 'falling' | 'stable' { + if (values.length < 2) return 'stable'; + + const mid = Math.floor(values.length / 2); + const firstHalf = values.slice(0, mid); + const secondHalf = values.slice(mid); + + const firstAvg = avg(firstHalf); + const secondAvg = avg(secondHalf); + const delta = secondAvg - firstAvg; + const threshold = firstAvg * 0.01; + + if (delta > threshold) return 'rising'; + if (delta < -threshold) return 'falling'; + return 'stable'; +} + +function avg(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((sum, v) => sum + v, 0) / values.length; +} diff --git a/tests/routes/stellar/fixtures/transfer-scenarios.ts b/tests/routes/stellar/fixtures/transfer-scenarios.ts new file mode 100644 index 00000000..06b97759 --- /dev/null +++ b/tests/routes/stellar/fixtures/transfer-scenarios.ts @@ -0,0 +1,254 @@ +import type { TransferRequest } from '../../../../src/routing/smart/stellar/soroban-smart-routing-engine'; +import type { FallbackReason } from '../../../../src/routing/fallbacks/stellar/types'; +import type { MockProviderConfig } from '../mocks/bridge-provider.mock'; + +export interface RouteSpec { + routeId: string; + providerId: string; + sourceChain: string; + destinationChain: string; + feeBase: number; + latencyMs: number; + maxSlippage?: number; + contractAddress?: string; +} + +export interface ExpectedOutcome { + /** Provider id that should be selected as the best route. */ + bestProvider?: string; + /** Route id that should be selected. */ + bestRouteId?: string; + /** Whether a fallback should be triggered. */ + fallbackTriggered?: boolean; + /** Expected fallback reason. */ + fallbackReason?: FallbackReason; + /** Upper bound on the winning route's fee. */ + maxFee?: number; + /** Upper bound on the winning route's latency in ms. */ + maxLatencyMs?: number; + /** Whether no route should be found (all providers failed). */ + noRouteFound?: boolean; +} + +export interface TransferScenario { + id: string; + description: string; + request: TransferRequest; + routes: RouteSpec[]; + /** Provider reliability overrides keyed by provider id. */ + reliabilityOverrides?: Record; + /** Simulates a primary route failure before fallback planning. */ + primaryFailure?: { + failedRouteId: string; + reason: FallbackReason; + }; + expected: ExpectedOutcome; +} + +// ─── Scenarios ─────────────────────────────────────────────────────────────── + +export const TRANSFER_SCENARIOS: Record = { + happyPath: { + id: 'happy-path', + description: 'Standard USDC transfer from Stellar to Ethereum with three healthy providers', + request: { + sourceChain: 'Stellar', + destinationChain: 'Ethereum', + asset: 'USDC', + amount: '1000.00', + sender: 'GABCDE', + recipient: '0xABCDE', + }, + routes: [ + { routeId: 'r-allbridge-eth', providerId: 'AllBridge', sourceChain: 'Stellar', destinationChain: 'Ethereum', feeBase: 1.5, latencyMs: 4200 }, + { routeId: 'r-squid-eth', providerId: 'Squid', sourceChain: 'Stellar', destinationChain: 'Ethereum', feeBase: 2.1, latencyMs: 6700 }, + { routeId: 'r-wormhole-eth', providerId: 'Wormhole', sourceChain: 'Stellar', destinationChain: 'Ethereum', feeBase: 1.2, latencyMs: 5100 }, + ], + expected: { + bestProvider: 'Wormhole', + maxFee: 2.5, + fallbackTriggered: false, + }, + }, + + cheapestRoute: { + id: 'cheapest-route', + description: 'Cost-prioritized routing should pick the lowest fee provider', + request: { + sourceChain: 'Stellar', + destinationChain: 'Polygon', + asset: 'USDC', + amount: '500.00', + sender: 'GABCDE', + recipient: '0xABCDE', + prioritize: 'cost', + }, + routes: [ + { routeId: 'r-stargate-poly', providerId: 'Stargate', sourceChain: 'Stellar', destinationChain: 'Polygon', feeBase: 0.6, latencyMs: 3500 }, + { routeId: 'r-allbridge-poly', providerId: 'AllBridge', sourceChain: 'Stellar', destinationChain: 'Polygon', feeBase: 1.5, latencyMs: 3100 }, + ], + expected: { + bestProvider: 'Stargate', + maxFee: 1.0, + }, + }, + + fastestRoute: { + id: 'fastest-route', + description: 'Speed-prioritized routing should pick the lowest latency provider', + request: { + sourceChain: 'Stellar', + destinationChain: 'Base', + asset: 'XLM', + amount: '10000.00', + sender: 'GABCDE', + recipient: '0xABCDE', + prioritize: 'speed', + }, + routes: [ + { routeId: 'r-allbridge-base', providerId: 'AllBridge', sourceChain: 'Stellar', destinationChain: 'Base', feeBase: 0.3, latencyMs: 2800 }, + { routeId: 'r-wormhole-base', providerId: 'Wormhole', sourceChain: 'Stellar', destinationChain: 'Base', feeBase: 1.8, latencyMs: 5200 }, + ], + expected: { + bestProvider: 'AllBridge', + maxLatencyMs: 3000, + }, + }, + + primaryProviderFails: { + id: 'primary-provider-fails', + description: 'Primary route fails mid-execution; fallback planner should select an alternative', + request: { + sourceChain: 'Stellar', + destinationChain: 'Ethereum', + asset: 'USDC', + amount: '5000.00', + sender: 'GABCDE', + recipient: '0xABCDE', + }, + routes: [ + { routeId: 'r-primary-eth', providerId: 'AllBridge', sourceChain: 'Stellar', destinationChain: 'Ethereum', feeBase: 1.5, latencyMs: 4200 }, + { routeId: 'r-fallback-eth', providerId: 'Wormhole', sourceChain: 'Stellar', destinationChain: 'Ethereum', feeBase: 1.2, latencyMs: 5100 }, + ], + primaryFailure: { + failedRouteId: 'r-primary-eth', + reason: 'provider_unavailable', + }, + expected: { + fallbackTriggered: true, + fallbackReason: 'provider_unavailable', + bestProvider: 'Wormhole', + }, + }, + + feeSpikeTriggersFailover: { + id: 'fee-spike-failover', + description: 'A sudden fee spike on the primary provider should trigger fallback to a cheaper route', + request: { + sourceChain: 'Stellar', + destinationChain: 'Ethereum', + asset: 'USDC', + amount: '2000.00', + sender: 'GABCDE', + recipient: '0xABCDE', + }, + routes: [ + { routeId: 'r-spiked', providerId: 'Squid', sourceChain: 'Stellar', destinationChain: 'Ethereum', feeBase: 2.1, latencyMs: 6700 }, + { routeId: 'r-stable', providerId: 'AllBridge', sourceChain: 'Stellar', destinationChain: 'Ethereum', feeBase: 1.5, latencyMs: 4200 }, + ], + primaryFailure: { + failedRouteId: 'r-spiked', + reason: 'fee_spike', + }, + expected: { + fallbackTriggered: true, + fallbackReason: 'fee_spike', + bestProvider: 'AllBridge', + }, + }, + + insufficientLiquidity: { + id: 'insufficient-liquidity', + description: 'Primary route has insufficient liquidity; fallback should recover with a liquid alternative', + request: { + sourceChain: 'Stellar', + destinationChain: 'Solana', + asset: 'USDC', + amount: '100000.00', + sender: 'GABCDE', + recipient: 'SolRecipient', + }, + routes: [ + { routeId: 'r-dry', providerId: 'AllBridge', sourceChain: 'Stellar', destinationChain: 'Solana', feeBase: 0.8, latencyMs: 3000 }, + { routeId: 'r-liquid', providerId: 'Wormhole', sourceChain: 'Stellar', destinationChain: 'Solana', feeBase: 1.2, latencyMs: 5100 }, + ], + primaryFailure: { + failedRouteId: 'r-dry', + reason: 'insufficient_liquidity', + }, + expected: { + fallbackTriggered: true, + fallbackReason: 'insufficient_liquidity', + }, + }, + + allProvidersDown: { + id: 'all-providers-down', + description: 'No routes registered for the requested chain pair; engine returns null', + request: { + sourceChain: 'Stellar', + destinationChain: 'Ethereum', + asset: 'USDC', + amount: '1000.00', + sender: 'GABCDE', + recipient: '0xABCDE', + }, + routes: [], + expected: { + noRouteFound: true, + }, + }, + + reverseDirection: { + id: 'reverse-direction', + description: 'Ethereum to Stellar USDC transfer — verifies routing works for the reverse path', + request: { + sourceChain: 'Ethereum', + destinationChain: 'Stellar', + asset: 'USDC', + amount: '25000.00', + sender: '0xABCDE', + recipient: 'GABCDE', + }, + routes: [ + { routeId: 'r-squid-stellar', providerId: 'Squid', sourceChain: 'Ethereum', destinationChain: 'Stellar', feeBase: 2.1, latencyMs: 6700 }, + { routeId: 'r-wormhole-stellar', providerId: 'Wormhole', sourceChain: 'Ethereum', destinationChain: 'Stellar', feeBase: 1.2, latencyMs: 5100 }, + ], + expected: { + bestProvider: 'Wormhole', + maxFee: 2.5, + }, + }, + + singleProvider: { + id: 'single-provider', + description: 'Only one provider available — should select it without fallback', + request: { + sourceChain: 'Stellar', + destinationChain: 'Base', + asset: 'USDC', + amount: '8000.00', + sender: 'GABCDE', + recipient: '0xABCDE', + }, + routes: [ + { routeId: 'r-only', providerId: 'AllBridge', sourceChain: 'Stellar', destinationChain: 'Base', feeBase: 0.9, latencyMs: 3200 }, + ], + expected: { + bestProvider: 'AllBridge', + fallbackTriggered: false, + }, + }, +}; + +export const SCENARIO_IDS = Object.keys(TRANSFER_SCENARIOS) as Array; diff --git a/tests/routes/stellar/harness/route-test-harness.ts b/tests/routes/stellar/harness/route-test-harness.ts new file mode 100644 index 00000000..749fd6b8 --- /dev/null +++ b/tests/routes/stellar/harness/route-test-harness.ts @@ -0,0 +1,244 @@ +import { + SorobanSmartRoutingEngine, + Route, + RouteEvaluation, + TransferRequest, +} from '../../../../src/routing/smart/stellar/soroban-smart-routing-engine'; +import { + SorobanRouteFallbackPlanner, +} from '../../../../src/routing/fallbacks/stellar/soroban-route-fallback-planner'; +import type { FallbackPlanResult, FallbackReason } from '../../../../src/routing/fallbacks/stellar/types'; +import { MockBridgeProvider, createMockProvider } from '../mocks/bridge-provider.mock'; +import type { TransferScenario, RouteSpec } from '../fixtures/transfer-scenarios'; + +export interface HarnessRunResult { + scenarioId: string; + /** Best route selected by the routing engine, null if none qualify. */ + selected: RouteEvaluation | null; + /** All ranked evaluations returned by the engine. */ + ranked: RouteEvaluation[]; + /** Fallback plan result when a primary failure was simulated. */ + fallback: FallbackPlanResult | null; + /** Milliseconds taken for the full simulation. */ + durationMs: number; + /** Providers that were called during the simulation. */ + calledProviders: string[]; + /** Errors collected during the run (non-fatal). */ + errors: string[]; +} + +export interface HarnessConfig { + /** Default reliability applied to providers not explicitly overridden. */ + defaultReliability?: number; + /** Max alternatives the fallback planner may return. */ + maxFallbackAlternatives?: number; + /** Minimum reliability threshold for fallback candidates. */ + fallbackMinReliability?: number; +} + +// ─── RouteTestHarness ───────────────────────────────────────────────────────── + +/** + * Reusable harness for running Soroban route execution scenarios. + * + * Wires together `SorobanSmartRoutingEngine` and `SorobanRouteFallbackPlanner` + * using mock providers, so tests can simulate real routing logic without + * external dependencies. + * + * Usage: + * const harness = new RouteTestHarness(); + * const result = harness.run(TRANSFER_SCENARIOS.happyPath); + * RouteValidator.assertBestProvider(result, 'AllBridge'); + */ +export class RouteTestHarness { + private readonly config: Required; + private readonly providers = new Map(); + + constructor(config: HarnessConfig = {}) { + this.config = { + defaultReliability: config.defaultReliability ?? 0.9, + maxFallbackAlternatives: config.maxFallbackAlternatives ?? 3, + fallbackMinReliability: config.fallbackMinReliability ?? 0.5, + }; + } + + // ─── Provider registration ──────────────────────────────────────────────── + + registerProvider(provider: MockBridgeProvider): this { + this.providers.set(provider.id, provider); + return this; + } + + registerProviders(providers: MockBridgeProvider[]): this { + providers.forEach((p) => this.registerProvider(p)); + return this; + } + + getProvider(id: string): MockBridgeProvider | undefined { + return this.providers.get(id); + } + + clearProviders(): this { + this.providers.clear(); + return this; + } + + // ─── Simulation ─────────────────────────────────────────────────────────── + + /** + * Run a complete transfer scenario through the routing engine and optional + * fallback planner, returning a structured result for assertion. + */ + run(scenario: TransferScenario): HarnessRunResult { + const start = Date.now(); + const errors: string[] = []; + const calledProviders: string[] = []; + + // Build routing engine + const engine = new SorobanSmartRoutingEngine(); + + // Build fallback planner + const planner = new SorobanRouteFallbackPlanner({ + failoverPolicy: { + autoFailover: true, + maxAlternatives: this.config.maxFallbackAlternatives, + minReliabilityThreshold: this.config.fallbackMinReliability, + }, + }); + + // Register routes and configure reliability + const routes = this._buildRoutes(scenario, calledProviders, errors); + engine.registerRoutes(routes); + planner.registerRoutes(routes); + + this._applyReliability(engine, planner, scenario); + + // Route selection + const selected = engine.selectRoute(scenario.request); + const ranked = engine.rankRoutes(scenario.request); + + // Fallback simulation + let fallback: FallbackPlanResult | null = null; + if (scenario.primaryFailure) { + const failedRoute = routes.find((r) => r.id === scenario.primaryFailure!.failedRouteId); + if (failedRoute) { + fallback = planner.plan(failedRoute, scenario.primaryFailure.reason); + } else { + errors.push(`primaryFailure.failedRouteId "${scenario.primaryFailure.failedRouteId}" not found in routes`); + } + } + + return { + scenarioId: scenario.id, + selected, + ranked, + fallback, + durationMs: Date.now() - start, + calledProviders, + errors, + }; + } + + /** + * Run multiple scenarios and return all results indexed by scenario id. + */ + runAll(scenarios: TransferScenario[]): Map { + const results = new Map(); + for (const scenario of scenarios) { + results.set(scenario.id, this.run(scenario)); + } + return results; + } + + /** + * Simulate a targeted fallback — register routes and trigger a plan directly. + */ + simulateFallback( + routes: Route[], + failedRouteId: string, + reason: FallbackReason, + reliabilityOverrides: Record = {}, + ): FallbackPlanResult | null { + const planner = new SorobanRouteFallbackPlanner({ + failoverPolicy: { + autoFailover: true, + maxAlternatives: this.config.maxFallbackAlternatives, + minReliabilityThreshold: this.config.fallbackMinReliability, + }, + }); + planner.registerRoutes(routes); + + for (const [providerId, score] of Object.entries(reliabilityOverrides)) { + planner.updateReliability(providerId, score); + } + + const failed = routes.find((r) => r.id === failedRouteId); + if (!failed) return null; + + return planner.plan(failed, reason); + } + + // ─── Internals ──────────────────────────────────────────────────────────── + + private _buildRoutes( + scenario: TransferScenario, + calledProviders: string[], + errors: string[], + ): Route[] { + return scenario.routes.map((spec) => { + let provider = this.providers.get(spec.providerId); + if (!provider) { + // Auto-create a provider from the spec when none is registered + provider = createMockProvider({ + id: spec.providerId, + reliability: this.config.defaultReliability, + latencyMs: spec.latencyMs, + feeBase: spec.feeBase, + }); + } + + const quote = provider.quoteRoute( + spec.sourceChain, + spec.destinationChain, + spec.routeId, + spec.contractAddress, + ); + + if (quote.simulatedError) { + errors.push(quote.simulatedError); + } + + if (!calledProviders.includes(spec.providerId)) { + calledProviders.push(spec.providerId); + } + + return quote.route; + }); + } + + private _applyReliability( + engine: SorobanSmartRoutingEngine, + planner: SorobanRouteFallbackPlanner, + scenario: TransferScenario, + ): void { + const overrides = scenario.reliabilityOverrides ?? {}; + + const allProviderIds = new Set(scenario.routes.map((r) => r.providerId)); + for (const providerId of allProviderIds) { + const score = overrides[providerId] ?? this._providerReliability(providerId); + engine.updateReliability(providerId, score); + planner.updateReliability(providerId, score); + } + } + + private _providerReliability(providerId: string): number { + const registered = this.providers.get(providerId); + return registered?.config.reliability ?? this.config.defaultReliability; + } +} + +// ─── Convenience factory ───────────────────────────────────────────────────── + +export function createHarness(config?: HarnessConfig): RouteTestHarness { + return new RouteTestHarness(config); +} diff --git a/tests/routes/stellar/harness/route-validator.ts b/tests/routes/stellar/harness/route-validator.ts new file mode 100644 index 00000000..2b25cb95 --- /dev/null +++ b/tests/routes/stellar/harness/route-validator.ts @@ -0,0 +1,238 @@ +import type { HarnessRunResult } from './route-test-harness'; +import type { ExpectedOutcome } from '../fixtures/transfer-scenarios'; + +/** + * Assertion helpers for `HarnessRunResult`. + * + * Each method throws a descriptive `Error` on violation rather than relying on + * a specific test framework, so the helpers work with Jest, Vitest, or plain + * Node test runners alike. + */ +export class RouteValidator { + // ─── Composite assertion ────────────────────────────────────────────────── + + /** + * Validate all fields of `expected` that are defined against `result`. + * Convenient for full-scenario assertions in one call. + */ + static assertOutcome(result: HarnessRunResult, expected: ExpectedOutcome): void { + if (expected.noRouteFound !== undefined) { + RouteValidator.assertNoRoute(result, expected.noRouteFound); + } + if (expected.bestProvider !== undefined) { + RouteValidator.assertBestProvider(result, expected.bestProvider); + } + if (expected.bestRouteId !== undefined) { + RouteValidator.assertBestRouteId(result, expected.bestRouteId); + } + if (expected.fallbackTriggered !== undefined) { + RouteValidator.assertFallbackTriggered(result, expected.fallbackTriggered); + } + if (expected.fallbackReason !== undefined) { + RouteValidator.assertFallbackReason(result, expected.fallbackReason); + } + if (expected.maxFee !== undefined) { + RouteValidator.assertFeeAtMost(result, expected.maxFee); + } + if (expected.maxLatencyMs !== undefined) { + RouteValidator.assertLatencyAtMost(result, expected.maxLatencyMs); + } + } + + // ─── Route selection ────────────────────────────────────────────────────── + + static assertBestProvider(result: HarnessRunResult, expectedProvider: string): void { + const actual = RouteValidator._bestProvider(result); + if (actual !== expectedProvider) { + throw new Error( + `[${result.scenarioId}] Expected best provider "${expectedProvider}" but got "${actual ?? 'none'}".`, + ); + } + } + + static assertBestRouteId(result: HarnessRunResult, expectedRouteId: string): void { + const actual = RouteValidator._bestRouteId(result); + if (actual !== expectedRouteId) { + throw new Error( + `[${result.scenarioId}] Expected best route id "${expectedRouteId}" but got "${actual ?? 'none'}".`, + ); + } + } + + static assertNoRoute(result: HarnessRunResult, expectNone = true): void { + const hasRoute = RouteValidator._hasRoute(result); + if (expectNone && hasRoute) { + const provider = RouteValidator._bestProvider(result); + throw new Error( + `[${result.scenarioId}] Expected no route but one was selected (provider: "${provider}").`, + ); + } + if (!expectNone && !hasRoute) { + throw new Error( + `[${result.scenarioId}] Expected a route to be selected but none was found.`, + ); + } + } + + // ─── Fee & latency ──────────────────────────────────────────────────────── + + static assertFeeAtMost(result: HarnessRunResult, maxFee: number): void { + const fee = RouteValidator._bestFee(result); + if (fee === null) { + throw new Error(`[${result.scenarioId}] Cannot assert fee — no route was selected.`); + } + if (fee > maxFee) { + throw new Error( + `[${result.scenarioId}] Best route fee ${fee} exceeds maximum ${maxFee}.`, + ); + } + } + + static assertFeeAtLeast(result: HarnessRunResult, minFee: number): void { + const fee = RouteValidator._bestFee(result); + if (fee === null) { + throw new Error(`[${result.scenarioId}] Cannot assert fee — no route was selected.`); + } + if (fee < minFee) { + throw new Error( + `[${result.scenarioId}] Best route fee ${fee} is below expected minimum ${minFee}.`, + ); + } + } + + static assertLatencyAtMost(result: HarnessRunResult, maxMs: number): void { + const latency = RouteValidator._bestLatency(result); + if (latency === null) { + throw new Error(`[${result.scenarioId}] Cannot assert latency — no route was selected.`); + } + if (latency > maxMs) { + throw new Error( + `[${result.scenarioId}] Best route latency ${latency}ms exceeds maximum ${maxMs}ms.`, + ); + } + } + + // ─── Fallback ───────────────────────────────────────────────────────────── + + static assertFallbackTriggered(result: HarnessRunResult, expected: boolean): void { + const triggered = result.fallback !== null; + if (triggered !== expected) { + throw new Error( + `[${result.scenarioId}] Expected fallback ${expected ? 'to be' : 'not to be'} triggered.`, + ); + } + } + + static assertFallbackReason(result: HarnessRunResult, expectedReason: string): void { + if (!result.fallback) { + throw new Error( + `[${result.scenarioId}] Cannot assert fallback reason — no fallback was triggered.`, + ); + } + if (result.fallback.reason !== expectedReason) { + throw new Error( + `[${result.scenarioId}] Expected fallback reason "${expectedReason}" but got "${result.fallback.reason}".`, + ); + } + } + + static assertFallbackHasAlternatives(result: HarnessRunResult, minCount = 1): void { + if (!result.fallback) { + throw new Error( + `[${result.scenarioId}] Cannot assert fallback alternatives — no fallback was triggered.`, + ); + } + const count = result.fallback.alternatives.length; + if (count < minCount) { + throw new Error( + `[${result.scenarioId}] Expected at least ${minCount} fallback alternative(s) but got ${count}.`, + ); + } + } + + static assertFallbackBestProvider(result: HarnessRunResult, expectedProvider: string): void { + if (!result.fallback) { + throw new Error( + `[${result.scenarioId}] Cannot assert fallback best provider — no fallback was triggered.`, + ); + } + const actual = result.fallback.best?.route.provider ?? null; + if (actual !== expectedProvider) { + throw new Error( + `[${result.scenarioId}] Expected fallback best provider "${expectedProvider}" but got "${actual ?? 'none'}".`, + ); + } + } + + // ─── Ranking ────────────────────────────────────────────────────────────── + + static assertRankedCount(result: HarnessRunResult, expectedCount: number): void { + if (result.ranked.length !== expectedCount) { + throw new Error( + `[${result.scenarioId}] Expected ${expectedCount} ranked route(s) but got ${result.ranked.length}.`, + ); + } + } + + static assertRankedAtLeast(result: HarnessRunResult, minCount: number): void { + if (result.ranked.length < minCount) { + throw new Error( + `[${result.scenarioId}] Expected at least ${minCount} ranked route(s) but got ${result.ranked.length}.`, + ); + } + } + + static assertProviderInRanked(result: HarnessRunResult, providerId: string): void { + const found = result.ranked.some((e) => e.route.provider === providerId); + if (!found) { + throw new Error( + `[${result.scenarioId}] Expected provider "${providerId}" in ranked results but it was not present.`, + ); + } + } + + static assertProviderNotInRanked(result: HarnessRunResult, providerId: string): void { + const found = result.ranked.some((e) => e.route.provider === providerId); + if (found) { + throw new Error( + `[${result.scenarioId}] Expected provider "${providerId}" to be absent from ranked results but it was present.`, + ); + } + } + + // ─── Errors ─────────────────────────────────────────────────────────────── + + static assertNoErrors(result: HarnessRunResult): void { + if (result.errors.length > 0) { + throw new Error( + `[${result.scenarioId}] Unexpected errors during simulation:\n ${result.errors.join('\n ')}`, + ); + } + } + + // ─── Private helpers ────────────────────────────────────────────────────── + + private static _hasRoute(result: HarnessRunResult): boolean { + return result.selected !== null || (result.fallback?.best !== null && result.fallback !== null); + } + + private static _bestProvider(result: HarnessRunResult): string | null { + if (result.selected) return result.selected.route.provider; + return result.fallback?.best?.route.provider ?? null; + } + + private static _bestRouteId(result: HarnessRunResult): string | null { + if (result.selected) return result.selected.route.id; + return result.fallback?.best?.route.id ?? null; + } + + private static _bestFee(result: HarnessRunResult): number | null { + if (result.selected) return result.selected.route.estimatedFee; + return result.fallback?.best?.route.estimatedFee ?? null; + } + + private static _bestLatency(result: HarnessRunResult): number | null { + if (result.selected) return result.selected.route.estimatedTimeMs; + return result.fallback?.best?.route.estimatedTimeMs ?? null; + } +} diff --git a/tests/routes/stellar/mocks/bridge-provider.mock.ts b/tests/routes/stellar/mocks/bridge-provider.mock.ts new file mode 100644 index 00000000..367210df --- /dev/null +++ b/tests/routes/stellar/mocks/bridge-provider.mock.ts @@ -0,0 +1,134 @@ +import type { Route } from '../../../../src/routing/smart/stellar/soroban-smart-routing-engine'; + +export interface MockProviderConfig { + id: string; + reliability: number; + latencyMs: number; + feeBase: number; + /** When set, the mock injects this failure after `failAfter` calls. */ + failureMode?: { + reason: 'timeout' | 'liquidity' | 'slippage' | 'unavailable'; + failAfter?: number; + }; +} + +export interface MockRouteQuote { + route: Route; + resolvedAt: number; + simulatedError?: string; +} + +export class MockBridgeProvider { + readonly id: string; + readonly config: MockProviderConfig; + + private callCount = 0; + private readonly capturedCalls: Array<{ sourceChain: string; destinationChain: string; calledAt: number }> = []; + + constructor(config: MockProviderConfig) { + this.config = config; + this.id = config.id; + } + + quoteRoute( + sourceChain: string, + destinationChain: string, + routeId: string, + contractAddress?: string, + ): MockRouteQuote { + this.callCount += 1; + this.capturedCalls.push({ sourceChain, destinationChain, calledAt: Date.now() }); + + const { failureMode } = this.config; + if (failureMode) { + const threshold = failureMode.failAfter ?? 0; + if (this.callCount > threshold) { + return { + route: this._buildRoute(routeId, sourceChain, destinationChain, contractAddress), + resolvedAt: Date.now(), + simulatedError: `[${this.id}] ${failureMode.reason}`, + }; + } + } + + return { + route: this._buildRoute(routeId, sourceChain, destinationChain, contractAddress), + resolvedAt: Date.now(), + }; + } + + /** Number of times this provider has been called. */ + getCallCount(): number { + return this.callCount; + } + + getCapturedCalls(): typeof this.capturedCalls { + return [...this.capturedCalls]; + } + + reset(): void { + this.callCount = 0; + this.capturedCalls.length = 0; + } + + private _buildRoute( + routeId: string, + sourceChain: string, + destinationChain: string, + contractAddress?: string, + ): Route { + return { + id: routeId, + provider: this.id, + sourceChain, + destinationChain, + estimatedFee: this.config.feeBase, + estimatedTimeMs: this.config.latencyMs, + maxSlippage: 0.5, + contractAddress, + }; + } +} + +// ─── Factory helpers ────────────────────────────────────────────────────────── + +export function createMockProvider(overrides: Partial & { id: string }): MockBridgeProvider { + return new MockBridgeProvider({ + reliability: 0.95, + latencyMs: 3000, + feeBase: 1.0, + ...overrides, + }); +} + +export const MOCK_PROVIDERS = { + allbridge: (): MockBridgeProvider => + createMockProvider({ id: 'AllBridge', reliability: 0.97, latencyMs: 4200, feeBase: 1.5 }), + + squid: (): MockBridgeProvider => + createMockProvider({ id: 'Squid', reliability: 0.93, latencyMs: 6700, feeBase: 2.1 }), + + wormhole: (): MockBridgeProvider => + createMockProvider({ id: 'Wormhole', reliability: 0.95, latencyMs: 5100, feeBase: 1.2 }), + + stargate: (): MockBridgeProvider => + createMockProvider({ id: 'Stargate', reliability: 0.91, latencyMs: 3500, feeBase: 0.6 }), + + unstable: (): MockBridgeProvider => + createMockProvider({ + id: 'UnstableProvider', + reliability: 0.3, + latencyMs: 8000, + feeBase: 0.2, + failureMode: { reason: 'unavailable', failAfter: 0 }, + }), + + congested: (): MockBridgeProvider => + createMockProvider({ + id: 'CongestedProvider', + reliability: 0.8, + latencyMs: 12000, + feeBase: 5.0, + failureMode: { reason: 'timeout', failAfter: 1 }, + }), +}; diff --git a/tests/routes/stellar/soroban-route-execution.spec.ts b/tests/routes/stellar/soroban-route-execution.spec.ts new file mode 100644 index 00000000..e18f98ff --- /dev/null +++ b/tests/routes/stellar/soroban-route-execution.spec.ts @@ -0,0 +1,374 @@ +/** + * Soroban Route Execution — automated test suite + * + * Uses the RouteTestHarness + RouteValidator to verify that the + * SorobanSmartRoutingEngine and SorobanRouteFallbackPlanner behave + * correctly across realistic transfer scenarios. + */ + +import { RouteTestHarness, createHarness } from './harness/route-test-harness'; +import { RouteValidator } from './harness/route-validator'; +import { TRANSFER_SCENARIOS } from './fixtures/transfer-scenarios'; +import { MOCK_PROVIDERS, createMockProvider } from './mocks/bridge-provider.mock'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function freshHarness(): RouteTestHarness { + return createHarness({ defaultReliability: 0.9 }); +} + +// ─── Suite ─────────────────────────────────────────────────────────────────── + +describe('Soroban Route Execution Harness', () => { + + // ── Happy Path ───────────────────────────────────────────────────────────── + + describe('happy path — standard multi-provider routing', () => { + it('selects the lowest-fee provider across three healthy providers', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.happyPath); + + RouteValidator.assertNoErrors(result); + RouteValidator.assertNoRoute(result, false); + RouteValidator.assertBestProvider(result, 'Wormhole'); + RouteValidator.assertFeeAtMost(result, 2.5); + }); + + it('ranks all providers when multiple are available', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.happyPath); + + RouteValidator.assertRankedAtLeast(result, 1); + RouteValidator.assertProviderInRanked(result, 'AllBridge'); + RouteValidator.assertProviderInRanked(result, 'Squid'); + RouteValidator.assertProviderInRanked(result, 'Wormhole'); + }); + + it('completes within a reasonable time budget', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.happyPath); + + expect(result.durationMs).toBeLessThan(500); + }); + }); + + // ── Cost Prioritization ──────────────────────────────────────────────────── + + describe('cost prioritization', () => { + it('picks the cheapest provider when prioritize=cost', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.cheapestRoute); + + RouteValidator.assertNoErrors(result); + RouteValidator.assertBestProvider(result, 'Stargate'); + RouteValidator.assertFeeAtMost(result, 1.0); + }); + + it('returns a fee within expected bounds for cost-priority scenario', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.cheapestRoute); + + RouteValidator.assertFeeAtMost(result, 1.0); + RouteValidator.assertFeeAtLeast(result, 0.0); + }); + }); + + // ── Speed Prioritization ─────────────────────────────────────────────────── + + describe('speed prioritization', () => { + it('picks the fastest provider when prioritize=speed', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.fastestRoute); + + RouteValidator.assertNoErrors(result); + RouteValidator.assertBestProvider(result, 'AllBridge'); + RouteValidator.assertLatencyAtMost(result, 3000); + }); + }); + + // ── Fallback — Provider Unavailable ─────────────────────────────────────── + + describe('fallback — provider unavailable', () => { + it('triggers a fallback plan when the primary provider is unavailable', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.primaryProviderFails); + + RouteValidator.assertFallbackTriggered(result, true); + RouteValidator.assertFallbackReason(result, 'provider_unavailable'); + }); + + it('selects Wormhole as fallback when AllBridge is unavailable', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.primaryProviderFails); + + RouteValidator.assertFallbackBestProvider(result, 'Wormhole'); + }); + + it('fallback plan exposes at least one alternative', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.primaryProviderFails); + + RouteValidator.assertFallbackHasAlternatives(result, 1); + }); + + it('excludes the failed provider from fallback alternatives', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.primaryProviderFails); + + expect(result.fallback).not.toBeNull(); + const hasFailedProvider = result.fallback!.alternatives.some( + (a) => a.route.provider === 'AllBridge', + ); + expect(hasFailedProvider).toBe(false); + }); + }); + + // ── Fallback — Fee Spike ─────────────────────────────────────────────────── + + describe('fallback — fee spike', () => { + it('falls back to a stable route on fee_spike reason', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.feeSpikeTriggersFailover); + + RouteValidator.assertFallbackTriggered(result, true); + RouteValidator.assertFallbackReason(result, 'fee_spike'); + RouteValidator.assertFallbackBestProvider(result, 'AllBridge'); + }); + }); + + // ── Fallback — Insufficient Liquidity ───────────────────────────────────── + + describe('fallback — insufficient liquidity', () => { + it('recovers via fallback when primary route has insufficient liquidity', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.insufficientLiquidity); + + RouteValidator.assertFallbackTriggered(result, true); + RouteValidator.assertFallbackReason(result, 'insufficient_liquidity'); + RouteValidator.assertFallbackBestProvider(result, 'Wormhole'); + }); + }); + + // ── All Providers Down ───────────────────────────────────────────────────── + + describe('all providers down', () => { + it('returns no route when no routes are registered for the chain pair', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.allProvidersDown); + + RouteValidator.assertNoRoute(result, true); + }); + + it('selected is null when no routes match the requested chain pair', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.allProvidersDown); + + expect(result.selected).toBeNull(); + expect(result.ranked).toHaveLength(0); + }); + }); + + // ── Reverse Direction ────────────────────────────────────────────────────── + + describe('reverse direction — Ethereum to Stellar', () => { + it('routes correctly for the reverse transfer path', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.reverseDirection); + + RouteValidator.assertNoErrors(result); + RouteValidator.assertBestProvider(result, 'Wormhole'); + RouteValidator.assertFeeAtMost(result, 2.5); + }); + }); + + // ── Single Provider ──────────────────────────────────────────────────────── + + describe('single provider available', () => { + it('selects the only provider without triggering fallback', () => { + const harness = freshHarness(); + const result = harness.run(TRANSFER_SCENARIOS.singleProvider); + + RouteValidator.assertBestProvider(result, 'AllBridge'); + RouteValidator.assertFallbackTriggered(result, false); + }); + }); + + // ── Registered Mock Providers ───────────────────────────────────────────── + + describe('registered mock providers', () => { + it('uses registered provider config (latency, fee) when building routes', () => { + const customAllbridge = createMockProvider({ + id: 'AllBridge', + reliability: 0.97, + latencyMs: 2000, + feeBase: 0.5, + }); + + const harness = freshHarness().registerProvider(customAllbridge); + const result = harness.run(TRANSFER_SCENARIOS.happyPath); + + RouteValidator.assertBestProvider(result, 'AllBridge'); + RouteValidator.assertFeeAtMost(result, 1.0); + expect(customAllbridge.getCallCount()).toBeGreaterThan(0); + }); + + it('tracks call counts on registered mock providers', () => { + const allbridge = MOCK_PROVIDERS.allbridge(); + const wormhole = MOCK_PROVIDERS.wormhole(); + + const harness = freshHarness().registerProviders([allbridge, wormhole]); + harness.run(TRANSFER_SCENARIOS.primaryProviderFails); + + expect(allbridge.getCallCount()).toBeGreaterThan(0); + expect(wormhole.getCallCount()).toBeGreaterThan(0); + }); + + it('captures call details for each quoted route', () => { + const allbridge = MOCK_PROVIDERS.allbridge(); + const harness = freshHarness().registerProvider(allbridge); + harness.run(TRANSFER_SCENARIOS.happyPath); + + const calls = allbridge.getCapturedCalls(); + expect(calls.length).toBeGreaterThan(0); + expect(calls[0].sourceChain).toBe('Stellar'); + expect(calls[0].destinationChain).toBe('Ethereum'); + }); + + it('resets call state between test runs when reset() is called', () => { + const allbridge = MOCK_PROVIDERS.allbridge(); + const harness = freshHarness().registerProvider(allbridge); + + harness.run(TRANSFER_SCENARIOS.happyPath); + expect(allbridge.getCallCount()).toBeGreaterThan(0); + + allbridge.reset(); + expect(allbridge.getCallCount()).toBe(0); + }); + }); + + // ── Unstable Provider ───────────────────────────────────────────────────── + + describe('unstable provider failure injection', () => { + it('captures simulated errors from an unstable provider', () => { + const unstable = MOCK_PROVIDERS.unstable(); + const harness = freshHarness().registerProvider(unstable); + + const scenario = TRANSFER_SCENARIOS.happyPath; + const modifiedScenario = { + ...scenario, + routes: [ + { + routeId: 'r-unstable-eth', + providerId: 'UnstableProvider', + sourceChain: 'Stellar', + destinationChain: 'Ethereum', + feeBase: 0.2, + latencyMs: 8000, + }, + ...scenario.routes.slice(1), + ], + }; + + const result = harness.run(modifiedScenario); + expect(result.errors.some((e) => e.includes('UnstableProvider'))).toBe(true); + }); + }); + + // ── Harness simulateFallback API ────────────────────────────────────────── + + describe('harness.simulateFallback()', () => { + it('returns a plan with alternatives for a targeted fallback simulation', () => { + const harness = freshHarness(); + const routes = [ + { + id: 'primary', + provider: 'AllBridge', + sourceChain: 'Stellar', + destinationChain: 'Ethereum', + estimatedFee: 1.5, + estimatedTimeMs: 4200, + maxSlippage: 0.5, + }, + { + id: 'backup', + provider: 'Wormhole', + sourceChain: 'Stellar', + destinationChain: 'Ethereum', + estimatedFee: 1.2, + estimatedTimeMs: 5100, + maxSlippage: 0.5, + }, + ]; + + const plan = harness.simulateFallback(routes, 'primary', 'execution_timeout'); + + expect(plan).not.toBeNull(); + expect(plan!.reason).toBe('execution_timeout'); + expect(plan!.alternatives.length).toBeGreaterThan(0); + expect(plan!.best?.route.provider).toBe('Wormhole'); + }); + + it('returns null when the specified failedRouteId does not exist', () => { + const harness = freshHarness(); + const plan = harness.simulateFallback([], 'non-existent-id', 'fee_spike'); + expect(plan).toBeNull(); + }); + + it('respects reliability overrides in targeted fallback', () => { + const harness = freshHarness(); + const routes = [ + { + id: 'primary', + provider: 'AllBridge', + sourceChain: 'Stellar', + destinationChain: 'Ethereum', + estimatedFee: 1.5, + estimatedTimeMs: 4200, + maxSlippage: 0.5, + }, + { + id: 'low-reliability', + provider: 'Squid', + sourceChain: 'Stellar', + destinationChain: 'Ethereum', + estimatedFee: 0.5, + estimatedTimeMs: 2000, + maxSlippage: 0.5, + }, + ]; + + // Squid has zero reliability — it should be excluded from the plan + const plan = harness.simulateFallback(routes, 'primary', 'provider_unavailable', { + Squid: 0, + }); + + expect(plan).not.toBeNull(); + expect(plan!.alternatives.length).toBe(0); + expect(plan!.best).toBeNull(); + }); + }); + + // ── Batch run ───────────────────────────────────────────────────────────── + + describe('batch scenario execution', () => { + it('runs all predefined scenarios without throwing', () => { + const harness = freshHarness(); + const scenarios = Object.values(TRANSFER_SCENARIOS); + const results = harness.runAll(scenarios); + + expect(results.size).toBe(scenarios.length); + for (const [id, result] of results) { + expect(result.scenarioId).toBe(id); + } + }); + + it('validates expected outcomes for every scenario in the fixture set', () => { + const harness = freshHarness(); + + for (const scenario of Object.values(TRANSFER_SCENARIOS)) { + const result = harness.run(scenario); + expect(() => RouteValidator.assertOutcome(result, scenario.expected)).not.toThrow(); + } + }); + }); +});