From 39e799800f5f1266feb46a6d801512d2d9001b0e Mon Sep 17 00:00:00 2001 From: victor-134 Date: Mon, 29 Jun 2026 23:47:31 +0100 Subject: [PATCH 1/4] Fix relayer jest config and expand watchdog tests --- relayer/jest.config.js | 20 ++++ relayer/src/refund-watchdog.test.ts | 145 ++++++++++++++++++++++++++++ relayer/tsconfig.json | 5 +- 3 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 relayer/jest.config.js create mode 100644 relayer/src/refund-watchdog.test.ts diff --git a/relayer/jest.config.js b/relayer/jest.config.js new file mode 100644 index 0000000..55db468 --- /dev/null +++ b/relayer/jest.config.js @@ -0,0 +1,20 @@ +export default { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + // Resolve the SDK logging sub-path export directly from TypeScript + // source so tests run without a prior `pnpm --filter @oversync/sdk build`. + '^@oversync/sdk/logging$': '/../packages/sdk/src/logging/index.ts', + // Strip .js extensions so ts-jest can find the TypeScript source. + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + useESM: true, + }, + ], + }, +}; diff --git a/relayer/src/refund-watchdog.test.ts b/relayer/src/refund-watchdog.test.ts new file mode 100644 index 0000000..01a4387 --- /dev/null +++ b/relayer/src/refund-watchdog.test.ts @@ -0,0 +1,145 @@ +/** + * Tests for relayer/src/refund-watchdog.ts + * + * Coverage areas that map to the log-redaction PR: + * 1. startRefundWatchdog lifecycle (start / stop). + * 2. Order-filter guards (isXlmToEthAwaitingEth is private, so tested + * indirectly via the tick that runs on a very short intervalMs). + * 3. redactLogValue — imported from @oversync/sdk/logging and called in + * the catch block — is the core change in this PR; we verify its + * contract here so the relayer package owns a passing test for it. + */ + +import { startRefundWatchdog } from './refund-watchdog.js'; +import { redactLogValue, redactLogString, isSensitiveLogKey } from '@oversync/sdk/logging'; + +// --------------------------------------------------------------------------- +// 1. Lifecycle +// --------------------------------------------------------------------------- + +describe('startRefundWatchdog – lifecycle', () => { + it('returns a stop function', () => { + const watchdog = startRefundWatchdog({ + horizonUrl: 'https://horizon-testnet.stellar.org', + refundSecret: 'SCZANGBA5AKIA4CRW3XGUA72XJMX5CZ3FDQJK3WSQ7S2VMQMKXD5BYXA', + networkMode: 'testnet', + activeOrders: new Map(), + }); + + expect(typeof watchdog.stop).toBe('function'); + watchdog.stop(); // must not throw + }); + + it('stop() is idempotent – calling twice does not throw', () => { + const watchdog = startRefundWatchdog({ + horizonUrl: 'https://horizon-testnet.stellar.org', + refundSecret: 'SCZANGBA5AKIA4CRW3XGUA72XJMX5CZ3FDQJK3WSQ7S2VMQMKXD5BYX', + networkMode: 'testnet', + activeOrders: new Map(), + }); + + expect(() => { + watchdog.stop(); + watchdog.stop(); + }).not.toThrow(); + }); + + it('accepts mainnet networkMode', () => { + const watchdog = startRefundWatchdog({ + horizonUrl: 'https://horizon.stellar.org', + refundSecret: 'SCZANGBA5AKIA4CRW3XGUA72XJMX5CZ3FDQJK3WSQ7S2VMQMKXD5BYX', + networkMode: 'mainnet', + activeOrders: new Map(), + }); + expect(typeof watchdog.stop).toBe('function'); + watchdog.stop(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Log-redaction helpers (used in the PR's catch block in refund-watchdog.ts) +// --------------------------------------------------------------------------- + +describe('redactLogValue – used in refund-watchdog catch block', () => { + it('redacts a Stellar secret embedded in an error message string', () => { + const secret = 'SCZANGBA5AKIA4CRW3XGUA72XJMX5CZ3FDQJK3WSQ7S2VMQMKXD5BYXA'; + const result = redactLogValue(`refund failed: secret=${secret}`); + expect(result).toBe('refund failed: secret=[REDACTED]'); + expect(result as string).not.toContain(secret); + }); + + it('redacts a 64-byte hex private key embedded in an error message', () => { + const privKey = '0x' + 'a'.repeat(64); + const result = redactLogValue(`eth send failed: key=${privKey}`); + expect(result as string).not.toContain(privKey); + expect(result).toBe(`eth send failed: key=[REDACTED]`); + }); + + it('redacts an Error object message containing a secret', () => { + const secret = 'SCZANGBA5AKIA4CRW3XGUA72XJMX5CZ3FDQJK3WSQ7S2VMQMKXD5BYXA'; + const err = new Error(`Horizon submit failed: ${secret}`); + const result = redactLogValue(err) as { message: string }; + expect(result.message).not.toContain(secret); + expect(result.message).toContain('[REDACTED]'); + }); + + it('passes through safe strings unchanged', () => { + const safe = 'refund failed for order ord_abc123: timeout'; + expect(redactLogValue(safe)).toBe(safe); + }); + + it('passes through non-string primitives unchanged', () => { + expect(redactLogValue(42)).toBe(42); + expect(redactLogValue(null)).toBe(null); + expect(redactLogValue(true)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 3. redactLogString – used in index.ts for private-key log lines +// --------------------------------------------------------------------------- + +describe('redactLogString – used in index.ts private key logs', () => { + it('strips a Bearer token from a log line', () => { + const line = 'Using real private key: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig'; + expect(redactLogString(line)).toBe('Using real private key: Bearer [REDACTED]'); + }); + + it('strips a 64-byte hex string from a log line', () => { + const privKey = '0x' + 'b'.repeat(64); + const line = `🔑 Using real private key: ${privKey}`; + expect(redactLogString(line)).not.toContain(privKey); + expect(redactLogString(line)).toContain('[REDACTED]'); + }); + + it('leaves a non-sensitive log line untouched', () => { + const line = '🛡️ Watchdog started on testnet, scanning every 60s'; + expect(redactLogString(line)).toBe(line); + }); +}); + +// --------------------------------------------------------------------------- +// 4. isSensitiveLogKey – normalisation rules +// --------------------------------------------------------------------------- + +describe('isSensitiveLogKey – key normalisation', () => { + it('matches "secret" in any casing', () => { + expect(isSensitiveLogKey('secret')).toBe(true); + expect(isSensitiveLogKey('Secret')).toBe(true); + expect(isSensitiveLogKey('SECRET')).toBe(true); + }); + + it('matches keys with separators stripped', () => { + expect(isSensitiveLogKey('private-key')).toBe(true); + expect(isSensitiveLogKey('private-key')).toBe(true); + expect(isSensitiveLogKey('signed_xdr')).toBe(true); + expect(isSensitiveLogKey('resolver_secret')).toBe(true); + }); + + it('does not flag safe keys', () => { + expect(isSensitiveLogKey('orderId')).toBe(false); + expect(isSensitiveLogKey('status')).toBe(false); + expect(isSensitiveLogKey('networkMode')).toBe(false); + expect(isSensitiveLogKey('stellarAddress')).toBe(false); + }); +}); diff --git a/relayer/tsconfig.json b/relayer/tsconfig.json index b4b4112..b8041de 100644 --- a/relayer/tsconfig.json +++ b/relayer/tsconfig.json @@ -17,7 +17,10 @@ "removeComments": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "types": ["node"] + "types": ["node", "jest"], + "paths": { + "@oversync/sdk/logging": ["../packages/sdk/src/logging/index.ts"] + } }, "include": [ "src/**/*" From f1806862045eaba29c51bf25782c90a500bfd2c9 Mon Sep 17 00:00:00 2001 From: victor-134 Date: Tue, 30 Jun 2026 10:39:08 +0100 Subject: [PATCH 2/4] fix: align relayer test runner and fix build --- packages/sdk/src/logging/index.ts | 24 + pnpm-lock.yaml | 129 +++++- relayer/jest.config.js | 1 + relayer/package.json | 11 +- relayer/test/relay-submission-tracker.test.ts | 438 +++++++++--------- relayer/tsconfig.json | 3 +- relayer/vitest.config.ts | 8 - 7 files changed, 376 insertions(+), 238 deletions(-) create mode 100644 packages/sdk/src/logging/index.ts delete mode 100644 relayer/vitest.config.ts diff --git a/packages/sdk/src/logging/index.ts b/packages/sdk/src/logging/index.ts new file mode 100644 index 0000000..16d7741 --- /dev/null +++ b/packages/sdk/src/logging/index.ts @@ -0,0 +1,24 @@ +export function redactLogValue(val: any): any { + if (typeof val === 'string') { + return redactLogString(val); + } + if (val instanceof Error) { + return { message: redactLogString(val.message) }; + } + return val; +} + +export function redactLogString(line: string): string { + // Redact Bearer tokens + line = line.replace(/Bearer [A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*/g, 'Bearer [REDACTED]'); + // Redact 64-byte hex (eth private keys) + line = line.replace(/0x[a-fA-F0-9]{64}/g, '[REDACTED]'); + // Redact Stellar secrets + line = line.replace(/S[A-Z2-7]{55}/g, '[REDACTED]'); + return line; +} + +export function isSensitiveLogKey(key: string): boolean { + const normalized = key.toLowerCase().replace(/[-_]/g, ''); + return normalized.includes('secret') || normalized.includes('privatekey') || normalized.includes('signedxdr'); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb99fd8..a95d13d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,6 +293,9 @@ importers: relayer: dependencies: + '@oversync/sdk': + specifier: workspace:^ + version: link:../packages/sdk '@stellar/stellar-sdk': specifier: ^11.3.0 version: 11.3.0 @@ -321,18 +324,24 @@ importers: specifier: ^3.11.0 version: 3.17.0 devDependencies: + '@types/jest': + specifier: ^29.5.0 + version: 29.5.14 '@typescript-eslint/eslint-plugin': specifier: ^6.16.0 version: 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1)(typescript@5.8.3) '@typescript-eslint/parser': specifier: ^6.16.0 version: 6.21.0(eslint@8.57.1)(typescript@5.8.3) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.19) + ts-jest: + specifier: ^29.1.0 + version: 29.4.0(@babel/core@7.28.0)(jest@29.7.0)(typescript@5.8.3) tsx: specifier: ^4.6.0 version: 4.20.3 - vitest: - specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19) resolver: dependencies: @@ -3871,7 +3880,7 @@ packages: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.17 - vite: 5.4.19(@types/node@22.19.19) + vite: 5.4.19(@types/node@20.19.7) dev: true /@vitest/pretty-format@2.1.9: @@ -5743,6 +5752,25 @@ packages: - ts-node dev: true + /create-jest@29.7.0(@types/node@22.19.19): + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@22.19.19) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + dev: true + /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} dev: true @@ -7174,7 +7202,7 @@ packages: /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -7938,6 +7966,34 @@ packages: - ts-node dev: true + /jest-cli@29.7.0(@types/node@22.19.19): + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@22.19.19) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@22.19.19) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + dev: true + /jest-config@29.7.0(@types/node@20.19.7): resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7978,6 +8034,46 @@ packages: - supports-color dev: true + /jest-config@29.7.0(@types/node@22.19.19): + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + dependencies: + '@babel/core': 7.28.0 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.19.19 + babel-jest: 29.7.0(@babel/core@7.28.0) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + dev: true + /jest-diff@29.7.0: resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8282,6 +8378,27 @@ packages: - ts-node dev: true + /jest@29.7.0(@types/node@22.19.19): + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.19.19) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + dev: true + /jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true @@ -11217,7 +11334,7 @@ packages: bs-logger: 0.2.6 ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@20.19.7) + jest: 29.7.0(@types/node@22.19.19) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 diff --git a/relayer/jest.config.js b/relayer/jest.config.js index 55db468..f945439 100644 --- a/relayer/jest.config.js +++ b/relayer/jest.config.js @@ -14,6 +14,7 @@ export default { 'ts-jest', { useESM: true, + isolatedModules: true, }, ], }, diff --git a/relayer/package.json b/relayer/package.json index 0ce65bd..1deb5d3 100644 --- a/relayer/package.json +++ b/relayer/package.json @@ -8,12 +8,13 @@ "start": "node dist/index.js", "dev": "tsx watch src/index.ts", "build": "tsc", - "test": "vitest run", - "test:watch": "vitest", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", + "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", "lint": "eslint src --ext .ts", "clean": "rm -rf dist" }, "dependencies": { + "@oversync/sdk": "workspace:^", "@stellar/stellar-sdk": "^11.3.0", "@types/node": "^22.10.0", "axios": "^1.10.0", @@ -25,10 +26,12 @@ "winston": "^3.11.0" }, "devDependencies": { + "@types/jest": "^29.5.0", "@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/parser": "^6.16.0", - "tsx": "^4.6.0", - "vitest": "^2.1.0" + "jest": "^29.7.0", + "ts-jest": "^29.1.0", + "tsx": "^4.6.0" }, "keywords": [ "relayer", diff --git a/relayer/test/relay-submission-tracker.test.ts b/relayer/test/relay-submission-tracker.test.ts index b61f3e5..b4d3d92 100644 --- a/relayer/test/relay-submission-tracker.test.ts +++ b/relayer/test/relay-submission-tracker.test.ts @@ -1,219 +1,219 @@ -import { describe, it, expect, vi } from "vitest"; -import { - RelaySubmissionTracker, - RelayTerminalError, - RelayInFlightError, - RelayTimeoutError, - computeFingerprint, - type RelayAction, - type RelayTrackerEvent, -} from "../src/relay-submission-tracker.js"; - -const action = (over: Partial = {}): RelayAction => ({ - kind: "eth->xlm", - orderId: "order_123", - chain: "stellar", - destination: "GUSER...", - amount: "10.0000000", - ...over, -}); - -// No-op sleep so retry delays don't slow the suite down. -const noSleep = () => Promise.resolve(); - -describe("computeFingerprint", () => { - it("is deterministic and independent of extra-field ordering", () => { - const a = action({ extra: { a: 1, b: 2 } }); - const b = action({ extra: { b: 2, a: 1 } }); - expect(computeFingerprint(a)).toBe(computeFingerprint(b)); - }); - - it("differs when a material field differs", () => { - expect(computeFingerprint(action())).not.toBe( - computeFingerprint(action({ amount: "11.0000000" })) - ); - expect(computeFingerprint(action())).not.toBe( - computeFingerprint(action({ orderId: "order_999" })) - ); - }); -}); - -describe("successful relay (happy path is preserved)", () => { - it("runs the executor once and returns its result", async () => { - const tracker = new RelaySubmissionTracker({ sleep: noSleep }); - const executor = vi.fn().mockResolvedValue({ hash: "abc" }); - - const outcome = await tracker.submit(action(), executor); - - expect(executor).toHaveBeenCalledTimes(1); - expect(outcome.status).toBe("succeeded"); - expect(outcome.duplicate).toBe(false); - expect(outcome.result).toEqual({ hash: "abc" }); - expect(tracker.getRecord(action())?.status).toBe("succeeded"); - }); -}); - -describe("timeout retry", () => { - it("retries a timed-out attempt and can still succeed within budget", async () => { - const events: RelayTrackerEvent[] = []; - const tracker = new RelaySubmissionTracker({ - maxAttempts: 3, - timeoutMs: 10, - sleep: noSleep, - onEvent: (e) => events.push(e), - }); - - // First attempt never resolves -> hits the per-attempt timeout. - // Second attempt resolves immediately. - const executor = vi - .fn() - .mockImplementationOnce(() => new Promise(() => {})) - .mockResolvedValueOnce({ hash: "ok" }); - - const outcome = await tracker.submit(action(), executor); - - expect(executor).toHaveBeenCalledTimes(2); - expect(outcome.status).toBe("succeeded"); - const record = tracker.getRecord(action()); - expect(record?.attempts).toBe(2); - expect(events.map((e) => e.type)).toContain("retry"); - // The timeout surfaced as the last error before recovery. - expect(events.find((e) => e.type === "retry")?.error).toMatch(/timed out/i); - }); - - it("bounds retries so a perpetually timing-out relay cannot submit forever", async () => { - const tracker = new RelaySubmissionTracker({ - maxAttempts: 3, - timeoutMs: 10, - sleep: noSleep, - }); - // Always hangs -> always times out. - const executor = vi.fn().mockImplementation(() => new Promise(() => {})); - - await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( - RelayTerminalError - ); - // Exactly the budget, never more. - expect(executor).toHaveBeenCalledTimes(3); - const record = tracker.getRecord(action()); - expect(record?.status).toBe("failed"); - expect(record?.attempts).toBe(3); - expect(record?.lastError).toMatch(/timed out/i); - }); -}); - -describe("duplicate prevention", () => { - it("does not re-run the executor for an already-handled action", async () => { - const tracker = new RelaySubmissionTracker({ sleep: noSleep }); - const executor = vi.fn().mockResolvedValue({ hash: "first" }); - - const first = await tracker.submit(action(), executor); - const second = await tracker.submit(action(), executor); - - expect(executor).toHaveBeenCalledTimes(1); - expect(first.status).toBe("succeeded"); - expect(second.status).toBe("already_handled"); - expect(second.duplicate).toBe(true); - expect(second.result).toEqual({ hash: "first" }); - expect(tracker.getStats().duplicatesSkipped).toBe(1); - }); - - it("rejects a concurrent in-flight submission for the same key", async () => { - const tracker = new RelaySubmissionTracker({ sleep: noSleep }); - let release!: (v: { hash: string }) => void; - const executor = vi - .fn() - .mockImplementation(() => new Promise((r) => (release = r))); - - const inflight = tracker.submit(action(), executor); - await Promise.resolve(); // let the first attempt start - - await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( - RelayInFlightError - ); - - release({ hash: "done" }); - await inflight; - expect(executor).toHaveBeenCalledTimes(1); - expect(tracker.getStats().inFlightSkipped).toBe(1); - }); -}); - -describe("terminal failure", () => { - it("stops retrying on a non-retryable error", async () => { - const tracker = new RelaySubmissionTracker({ - maxAttempts: 5, - sleep: noSleep, - isRetryable: (err) => !(err instanceof Error && err.message.includes("INSUFFICIENT")), - }); - const executor = vi - .fn() - .mockRejectedValue(new Error("INSUFFICIENT FUNDS")); - - await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( - RelayTerminalError - ); - // Non-retryable -> only one attempt despite a budget of 5. - expect(executor).toHaveBeenCalledTimes(1); - expect(tracker.getRecord(action())?.status).toBe("failed"); - }); - - it("re-throws terminal failure for a duplicate without re-submitting", async () => { - const tracker = new RelaySubmissionTracker({ maxAttempts: 2, sleep: noSleep }); - const executor = vi.fn().mockRejectedValue(new Error("rpc exploded")); - - await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( - RelayTerminalError - ); - const callsAfterFirst = executor.mock.calls.length; - - // A later duplicate request must not broadcast again. - await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( - RelayTerminalError - ); - expect(executor).toHaveBeenCalledTimes(callsAfterFirst); - }); -}); - -describe("retry budget and last error visibility", () => { - it("exposes retry count, last error and terminal state via stats/records", async () => { - const tracker = new RelaySubmissionTracker({ maxAttempts: 3, sleep: noSleep }); - - const flaky = vi - .fn() - .mockRejectedValueOnce(new Error("temporary blip")) - .mockResolvedValueOnce({ hash: "recovered" }); - await tracker.submit(action({ orderId: "ok" }), flaky); - - const doomed = vi.fn().mockRejectedValue(new Error("permanent failure")); - await tracker - .submit(action({ orderId: "bad" }), doomed) - .catch(() => undefined); - - const stats = tracker.getStats(); - expect(stats.tracked).toBe(2); - expect(stats.succeeded).toBe(1); - expect(stats.failed).toBe(1); - expect(stats.retries).toBeGreaterThanOrEqual(1); - - const badRecord = tracker.getRecord(action({ orderId: "bad" })); - expect(badRecord?.attempts).toBe(3); - expect(badRecord?.lastError).toBe("permanent failure"); - }); - - it("throws RelayTimeoutError-shaped errors that the retry policy treats as retryable", async () => { - const tracker = new RelaySubmissionTracker({ - maxAttempts: 1, - timeoutMs: 5, - sleep: noSleep, - }); - let captured: unknown; - const executor = () => new Promise(() => {}); - await tracker.submit(action(), executor).catch((e) => (captured = e)); - // With a budget of 1, the timeout becomes the terminal error. - expect(captured).toBeInstanceOf(RelayTerminalError); - expect((captured as RelayTerminalError).lastError).toMatch(/timed out/i); - // Sanity: a raw timeout is its own error type. - expect(new RelayTimeoutError("x")).toBeInstanceOf(Error); - }); -}); +import { jest } from '@jest/globals'; +import { + RelaySubmissionTracker, + RelayTerminalError, + RelayInFlightError, + RelayTimeoutError, + computeFingerprint, + type RelayAction, + type RelayTrackerEvent, +} from "../src/relay-submission-tracker.js"; + +const action = (over: Partial = {}): RelayAction => ({ + kind: "eth->xlm", + orderId: "order_123", + chain: "stellar", + destination: "GUSER...", + amount: "10.0000000", + ...over, +}); + +// No-op sleep so retry delays don't slow the suite down. +const noSleep = () => Promise.resolve(); + +describe("computeFingerprint", () => { + it("is deterministic and independent of extra-field ordering", () => { + const a = action({ extra: { a: 1, b: 2 } }); + const b = action({ extra: { b: 2, a: 1 } }); + expect(computeFingerprint(a)).toBe(computeFingerprint(b)); + }); + + it("differs when a material field differs", () => { + expect(computeFingerprint(action())).not.toBe( + computeFingerprint(action({ amount: "11.0000000" })) + ); + expect(computeFingerprint(action())).not.toBe( + computeFingerprint(action({ orderId: "order_999" })) + ); + }); +}); + +describe("successful relay (happy path is preserved)", () => { + it("runs the executor once and returns its result", async () => { + const tracker = new RelaySubmissionTracker({ sleep: noSleep }); + const executor = jest.fn().mockResolvedValue({ hash: "abc" }); + + const outcome = await tracker.submit(action(), executor); + + expect(executor).toHaveBeenCalledTimes(1); + expect(outcome.status).toBe("succeeded"); + expect(outcome.duplicate).toBe(false); + expect(outcome.result).toEqual({ hash: "abc" }); + expect(tracker.getRecord(action())?.status).toBe("succeeded"); + }); +}); + +describe("timeout retry", () => { + it("retries a timed-out attempt and can still succeed within budget", async () => { + const events: RelayTrackerEvent[] = []; + const tracker = new RelaySubmissionTracker({ + maxAttempts: 3, + timeoutMs: 10, + sleep: noSleep, + onEvent: (e) => events.push(e), + }); + + // First attempt never resolves -> hits the per-attempt timeout. + // Second attempt resolves immediately. + const executor = jest + .fn() + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValueOnce({ hash: "ok" }); + + const outcome = await tracker.submit(action(), executor); + + expect(executor).toHaveBeenCalledTimes(2); + expect(outcome.status).toBe("succeeded"); + const record = tracker.getRecord(action()); + expect(record?.attempts).toBe(2); + expect(events.map((e) => e.type)).toContain("retry"); + // The timeout surfaced as the last error before recovery. + expect(events.find((e) => e.type === "retry")?.error).toMatch(/timed out/i); + }); + + it("bounds retries so a perpetually timing-out relay cannot submit forever", async () => { + const tracker = new RelaySubmissionTracker({ + maxAttempts: 3, + timeoutMs: 10, + sleep: noSleep, + }); + // Always hangs -> always times out. + const executor = jest.fn().mockImplementation(() => new Promise(() => {})); + + await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( + RelayTerminalError + ); + // Exactly the budget, never more. + expect(executor).toHaveBeenCalledTimes(3); + const record = tracker.getRecord(action()); + expect(record?.status).toBe("failed"); + expect(record?.attempts).toBe(3); + expect(record?.lastError).toMatch(/timed out/i); + }); +}); + +describe("duplicate prevention", () => { + it("does not re-run the executor for an already-handled action", async () => { + const tracker = new RelaySubmissionTracker({ sleep: noSleep }); + const executor = jest.fn().mockResolvedValue({ hash: "first" }); + + const first = await tracker.submit(action(), executor); + const second = await tracker.submit(action(), executor); + + expect(executor).toHaveBeenCalledTimes(1); + expect(first.status).toBe("succeeded"); + expect(second.status).toBe("already_handled"); + expect(second.duplicate).toBe(true); + expect(second.result).toEqual({ hash: "first" }); + expect(tracker.getStats().duplicatesSkipped).toBe(1); + }); + + it("rejects a concurrent in-flight submission for the same key", async () => { + const tracker = new RelaySubmissionTracker({ sleep: noSleep }); + let release!: (v: { hash: string }) => void; + const executor = jest + .fn() + .mockImplementation(() => new Promise((r) => (release = r))); + + const inflight = tracker.submit(action(), executor); + await Promise.resolve(); // let the first attempt start + + await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( + RelayInFlightError + ); + + release({ hash: "done" }); + await inflight; + expect(executor).toHaveBeenCalledTimes(1); + expect(tracker.getStats().inFlightSkipped).toBe(1); + }); +}); + +describe("terminal failure", () => { + it("stops retrying on a non-retryable error", async () => { + const tracker = new RelaySubmissionTracker({ + maxAttempts: 5, + sleep: noSleep, + isRetryable: (err) => !(err instanceof Error && err.message.includes("INSUFFICIENT")), + }); + const executor = jest + .fn() + .mockRejectedValue(new Error("INSUFFICIENT FUNDS")); + + await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( + RelayTerminalError + ); + // Non-retryable -> only one attempt despite a budget of 5. + expect(executor).toHaveBeenCalledTimes(1); + expect(tracker.getRecord(action())?.status).toBe("failed"); + }); + + it("re-throws terminal failure for a duplicate without re-submitting", async () => { + const tracker = new RelaySubmissionTracker({ maxAttempts: 2, sleep: noSleep }); + const executor = jest.fn().mockRejectedValue(new Error("rpc exploded")); + + await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( + RelayTerminalError + ); + const callsAfterFirst = executor.mock.calls.length; + + // A later duplicate request must not broadcast again. + await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( + RelayTerminalError + ); + expect(executor).toHaveBeenCalledTimes(callsAfterFirst); + }); +}); + +describe("retry budget and last error visibility", () => { + it("exposes retry count, last error and terminal state via stats/records", async () => { + const tracker = new RelaySubmissionTracker({ maxAttempts: 3, sleep: noSleep }); + + const flaky = jest + .fn() + .mockRejectedValueOnce(new Error("temporary blip")) + .mockResolvedValueOnce({ hash: "recovered" }); + await tracker.submit(action({ orderId: "ok" }), flaky); + + const doomed = jest.fn().mockRejectedValue(new Error("permanent failure")); + await tracker + .submit(action({ orderId: "bad" }), doomed) + .catch(() => undefined); + + const stats = tracker.getStats(); + expect(stats.tracked).toBe(2); + expect(stats.succeeded).toBe(1); + expect(stats.failed).toBe(1); + expect(stats.retries).toBeGreaterThanOrEqual(1); + + const badRecord = tracker.getRecord(action({ orderId: "bad" })); + expect(badRecord?.attempts).toBe(3); + expect(badRecord?.lastError).toBe("permanent failure"); + }); + + it("throws RelayTimeoutError-shaped errors that the retry policy treats as retryable", async () => { + const tracker = new RelaySubmissionTracker({ + maxAttempts: 1, + timeoutMs: 5, + sleep: noSleep, + }); + let captured: unknown; + const executor = () => new Promise(() => {}); + await tracker.submit(action(), executor).catch((e) => (captured = e)); + // With a budget of 1, the timeout becomes the terminal error. + expect(captured).toBeInstanceOf(RelayTerminalError); + expect((captured as RelayTerminalError).lastError).toMatch(/timed out/i); + // Sanity: a raw timeout is its own error type. + expect(new RelayTimeoutError("x")).toBeInstanceOf(Error); + }); +}); diff --git a/relayer/tsconfig.json b/relayer/tsconfig.json index b8041de..0706b4f 100644 --- a/relayer/tsconfig.json +++ b/relayer/tsconfig.json @@ -28,6 +28,7 @@ "exclude": [ "node_modules", "dist", - "test" + "test", + "src/**/*.test.ts" ] } \ No newline at end of file diff --git a/relayer/vitest.config.ts b/relayer/vitest.config.ts deleted file mode 100644 index ed078a3..0000000 --- a/relayer/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - include: ["test/**/*.test.ts"] - } -}); From 68ca01169b70ac3d81e44ae07917971ed7f9ddb5 Mon Sep 17 00:00:00 2001 From: victor-134 Date: Wed, 1 Jul 2026 15:24:57 +0100 Subject: [PATCH 3/4] Fix line endings in relay-submission-tracker.test.ts --- relayer/test/relay-submission-tracker.test.ts | 438 +++++++++--------- 1 file changed, 219 insertions(+), 219 deletions(-) diff --git a/relayer/test/relay-submission-tracker.test.ts b/relayer/test/relay-submission-tracker.test.ts index b4d3d92..ca653ec 100644 --- a/relayer/test/relay-submission-tracker.test.ts +++ b/relayer/test/relay-submission-tracker.test.ts @@ -1,219 +1,219 @@ -import { jest } from '@jest/globals'; -import { - RelaySubmissionTracker, - RelayTerminalError, - RelayInFlightError, - RelayTimeoutError, - computeFingerprint, - type RelayAction, - type RelayTrackerEvent, -} from "../src/relay-submission-tracker.js"; - -const action = (over: Partial = {}): RelayAction => ({ - kind: "eth->xlm", - orderId: "order_123", - chain: "stellar", - destination: "GUSER...", - amount: "10.0000000", - ...over, -}); - -// No-op sleep so retry delays don't slow the suite down. -const noSleep = () => Promise.resolve(); - -describe("computeFingerprint", () => { - it("is deterministic and independent of extra-field ordering", () => { - const a = action({ extra: { a: 1, b: 2 } }); - const b = action({ extra: { b: 2, a: 1 } }); - expect(computeFingerprint(a)).toBe(computeFingerprint(b)); - }); - - it("differs when a material field differs", () => { - expect(computeFingerprint(action())).not.toBe( - computeFingerprint(action({ amount: "11.0000000" })) - ); - expect(computeFingerprint(action())).not.toBe( - computeFingerprint(action({ orderId: "order_999" })) - ); - }); -}); - -describe("successful relay (happy path is preserved)", () => { - it("runs the executor once and returns its result", async () => { - const tracker = new RelaySubmissionTracker({ sleep: noSleep }); - const executor = jest.fn().mockResolvedValue({ hash: "abc" }); - - const outcome = await tracker.submit(action(), executor); - - expect(executor).toHaveBeenCalledTimes(1); - expect(outcome.status).toBe("succeeded"); - expect(outcome.duplicate).toBe(false); - expect(outcome.result).toEqual({ hash: "abc" }); - expect(tracker.getRecord(action())?.status).toBe("succeeded"); - }); -}); - -describe("timeout retry", () => { - it("retries a timed-out attempt and can still succeed within budget", async () => { - const events: RelayTrackerEvent[] = []; - const tracker = new RelaySubmissionTracker({ - maxAttempts: 3, - timeoutMs: 10, - sleep: noSleep, - onEvent: (e) => events.push(e), - }); - - // First attempt never resolves -> hits the per-attempt timeout. - // Second attempt resolves immediately. - const executor = jest - .fn() - .mockImplementationOnce(() => new Promise(() => {})) - .mockResolvedValueOnce({ hash: "ok" }); - - const outcome = await tracker.submit(action(), executor); - - expect(executor).toHaveBeenCalledTimes(2); - expect(outcome.status).toBe("succeeded"); - const record = tracker.getRecord(action()); - expect(record?.attempts).toBe(2); - expect(events.map((e) => e.type)).toContain("retry"); - // The timeout surfaced as the last error before recovery. - expect(events.find((e) => e.type === "retry")?.error).toMatch(/timed out/i); - }); - - it("bounds retries so a perpetually timing-out relay cannot submit forever", async () => { - const tracker = new RelaySubmissionTracker({ - maxAttempts: 3, - timeoutMs: 10, - sleep: noSleep, - }); - // Always hangs -> always times out. - const executor = jest.fn().mockImplementation(() => new Promise(() => {})); - - await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( - RelayTerminalError - ); - // Exactly the budget, never more. - expect(executor).toHaveBeenCalledTimes(3); - const record = tracker.getRecord(action()); - expect(record?.status).toBe("failed"); - expect(record?.attempts).toBe(3); - expect(record?.lastError).toMatch(/timed out/i); - }); -}); - -describe("duplicate prevention", () => { - it("does not re-run the executor for an already-handled action", async () => { - const tracker = new RelaySubmissionTracker({ sleep: noSleep }); - const executor = jest.fn().mockResolvedValue({ hash: "first" }); - - const first = await tracker.submit(action(), executor); - const second = await tracker.submit(action(), executor); - - expect(executor).toHaveBeenCalledTimes(1); - expect(first.status).toBe("succeeded"); - expect(second.status).toBe("already_handled"); - expect(second.duplicate).toBe(true); - expect(second.result).toEqual({ hash: "first" }); - expect(tracker.getStats().duplicatesSkipped).toBe(1); - }); - - it("rejects a concurrent in-flight submission for the same key", async () => { - const tracker = new RelaySubmissionTracker({ sleep: noSleep }); - let release!: (v: { hash: string }) => void; - const executor = jest - .fn() - .mockImplementation(() => new Promise((r) => (release = r))); - - const inflight = tracker.submit(action(), executor); - await Promise.resolve(); // let the first attempt start - - await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( - RelayInFlightError - ); - - release({ hash: "done" }); - await inflight; - expect(executor).toHaveBeenCalledTimes(1); - expect(tracker.getStats().inFlightSkipped).toBe(1); - }); -}); - -describe("terminal failure", () => { - it("stops retrying on a non-retryable error", async () => { - const tracker = new RelaySubmissionTracker({ - maxAttempts: 5, - sleep: noSleep, - isRetryable: (err) => !(err instanceof Error && err.message.includes("INSUFFICIENT")), - }); - const executor = jest - .fn() - .mockRejectedValue(new Error("INSUFFICIENT FUNDS")); - - await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( - RelayTerminalError - ); - // Non-retryable -> only one attempt despite a budget of 5. - expect(executor).toHaveBeenCalledTimes(1); - expect(tracker.getRecord(action())?.status).toBe("failed"); - }); - - it("re-throws terminal failure for a duplicate without re-submitting", async () => { - const tracker = new RelaySubmissionTracker({ maxAttempts: 2, sleep: noSleep }); - const executor = jest.fn().mockRejectedValue(new Error("rpc exploded")); - - await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( - RelayTerminalError - ); - const callsAfterFirst = executor.mock.calls.length; - - // A later duplicate request must not broadcast again. - await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( - RelayTerminalError - ); - expect(executor).toHaveBeenCalledTimes(callsAfterFirst); - }); -}); - -describe("retry budget and last error visibility", () => { - it("exposes retry count, last error and terminal state via stats/records", async () => { - const tracker = new RelaySubmissionTracker({ maxAttempts: 3, sleep: noSleep }); - - const flaky = jest - .fn() - .mockRejectedValueOnce(new Error("temporary blip")) - .mockResolvedValueOnce({ hash: "recovered" }); - await tracker.submit(action({ orderId: "ok" }), flaky); - - const doomed = jest.fn().mockRejectedValue(new Error("permanent failure")); - await tracker - .submit(action({ orderId: "bad" }), doomed) - .catch(() => undefined); - - const stats = tracker.getStats(); - expect(stats.tracked).toBe(2); - expect(stats.succeeded).toBe(1); - expect(stats.failed).toBe(1); - expect(stats.retries).toBeGreaterThanOrEqual(1); - - const badRecord = tracker.getRecord(action({ orderId: "bad" })); - expect(badRecord?.attempts).toBe(3); - expect(badRecord?.lastError).toBe("permanent failure"); - }); - - it("throws RelayTimeoutError-shaped errors that the retry policy treats as retryable", async () => { - const tracker = new RelaySubmissionTracker({ - maxAttempts: 1, - timeoutMs: 5, - sleep: noSleep, - }); - let captured: unknown; - const executor = () => new Promise(() => {}); - await tracker.submit(action(), executor).catch((e) => (captured = e)); - // With a budget of 1, the timeout becomes the terminal error. - expect(captured).toBeInstanceOf(RelayTerminalError); - expect((captured as RelayTerminalError).lastError).toMatch(/timed out/i); - // Sanity: a raw timeout is its own error type. - expect(new RelayTimeoutError("x")).toBeInstanceOf(Error); - }); -}); +import { jest } from '@jest/globals'; +import { + RelaySubmissionTracker, + RelayTerminalError, + RelayInFlightError, + RelayTimeoutError, + computeFingerprint, + type RelayAction, + type RelayTrackerEvent, +} from "../src/relay-submission-tracker.js"; + +const action = (over: Partial = {}): RelayAction => ({ + kind: "eth->xlm", + orderId: "order_123", + chain: "stellar", + destination: "GUSER...", + amount: "10.0000000", + ...over, +}); + +// No-op sleep so retry delays don't slow the suite down. +const noSleep = () => Promise.resolve(); + +describe("computeFingerprint", () => { + it("is deterministic and independent of extra-field ordering", () => { + const a = action({ extra: { a: 1, b: 2 } }); + const b = action({ extra: { b: 2, a: 1 } }); + expect(computeFingerprint(a)).toBe(computeFingerprint(b)); + }); + + it("differs when a material field differs", () => { + expect(computeFingerprint(action())).not.toBe( + computeFingerprint(action({ amount: "11.0000000" })) + ); + expect(computeFingerprint(action())).not.toBe( + computeFingerprint(action({ orderId: "order_999" })) + ); + }); +}); + +describe("successful relay (happy path is preserved)", () => { + it("runs the executor once and returns its result", async () => { + const tracker = new RelaySubmissionTracker({ sleep: noSleep }); + const executor = jest.fn().mockResolvedValue({ hash: "abc" }); + + const outcome = await tracker.submit(action(), executor); + + expect(executor).toHaveBeenCalledTimes(1); + expect(outcome.status).toBe("succeeded"); + expect(outcome.duplicate).toBe(false); + expect(outcome.result).toEqual({ hash: "abc" }); + expect(tracker.getRecord(action())?.status).toBe("succeeded"); + }); +}); + +describe("timeout retry", () => { + it("retries a timed-out attempt and can still succeed within budget", async () => { + const events: RelayTrackerEvent[] = []; + const tracker = new RelaySubmissionTracker({ + maxAttempts: 3, + timeoutMs: 10, + sleep: noSleep, + onEvent: (e) => events.push(e), + }); + + // First attempt never resolves -> hits the per-attempt timeout. + // Second attempt resolves immediately. + const executor = jest + .fn() + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValueOnce({ hash: "ok" }); + + const outcome = await tracker.submit(action(), executor); + + expect(executor).toHaveBeenCalledTimes(2); + expect(outcome.status).toBe("succeeded"); + const record = tracker.getRecord(action()); + expect(record?.attempts).toBe(2); + expect(events.map((e) => e.type)).toContain("retry"); + // The timeout surfaced as the last error before recovery. + expect(events.find((e) => e.type === "retry")?.error).toMatch(/timed out/i); + }); + + it("bounds retries so a perpetually timing-out relay cannot submit forever", async () => { + const tracker = new RelaySubmissionTracker({ + maxAttempts: 3, + timeoutMs: 10, + sleep: noSleep, + }); + // Always hangs -> always times out. + const executor = jest.fn().mockImplementation(() => new Promise(() => {})); + + await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( + RelayTerminalError + ); + // Exactly the budget, never more. + expect(executor).toHaveBeenCalledTimes(3); + const record = tracker.getRecord(action()); + expect(record?.status).toBe("failed"); + expect(record?.attempts).toBe(3); + expect(record?.lastError).toMatch(/timed out/i); + }); +}); + +describe("duplicate prevention", () => { + it("does not re-run the executor for an already-handled action", async () => { + const tracker = new RelaySubmissionTracker({ sleep: noSleep }); + const executor = jest.fn().mockResolvedValue({ hash: "first" }); + + const first = await tracker.submit(action(), executor); + const second = await tracker.submit(action(), executor); + + expect(executor).toHaveBeenCalledTimes(1); + expect(first.status).toBe("succeeded"); + expect(second.status).toBe("already_handled"); + expect(second.duplicate).toBe(true); + expect(second.result).toEqual({ hash: "first" }); + expect(tracker.getStats().duplicatesSkipped).toBe(1); + }); + + it("rejects a concurrent in-flight submission for the same key", async () => { + const tracker = new RelaySubmissionTracker({ sleep: noSleep }); + let release!: (v: { hash: string }) => void; + const executor = jest + .fn() + .mockImplementation(() => new Promise((r) => (release = r))); + + const inflight = tracker.submit(action(), executor); + await Promise.resolve(); // let the first attempt start + + await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( + RelayInFlightError + ); + + release({ hash: "done" }); + await inflight; + expect(executor).toHaveBeenCalledTimes(1); + expect(tracker.getStats().inFlightSkipped).toBe(1); + }); +}); + +describe("terminal failure", () => { + it("stops retrying on a non-retryable error", async () => { + const tracker = new RelaySubmissionTracker({ + maxAttempts: 5, + sleep: noSleep, + isRetryable: (err) => !(err instanceof Error && err.message.includes("INSUFFICIENT")), + }); + const executor = jest + .fn() + .mockRejectedValue(new Error("INSUFFICIENT FUNDS")); + + await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( + RelayTerminalError + ); + // Non-retryable -> only one attempt despite a budget of 5. + expect(executor).toHaveBeenCalledTimes(1); + expect(tracker.getRecord(action())?.status).toBe("failed"); + }); + + it("re-throws terminal failure for a duplicate without re-submitting", async () => { + const tracker = new RelaySubmissionTracker({ maxAttempts: 2, sleep: noSleep }); + const executor = jest.fn().mockRejectedValue(new Error("rpc exploded")); + + await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( + RelayTerminalError + ); + const callsAfterFirst = executor.mock.calls.length; + + // A later duplicate request must not broadcast again. + await expect(tracker.submit(action(), executor)).rejects.toBeInstanceOf( + RelayTerminalError + ); + expect(executor).toHaveBeenCalledTimes(callsAfterFirst); + }); +}); + +describe("retry budget and last error visibility", () => { + it("exposes retry count, last error and terminal state via stats/records", async () => { + const tracker = new RelaySubmissionTracker({ maxAttempts: 3, sleep: noSleep }); + + const flaky = jest + .fn() + .mockRejectedValueOnce(new Error("temporary blip")) + .mockResolvedValueOnce({ hash: "recovered" }); + await tracker.submit(action({ orderId: "ok" }), flaky); + + const doomed = jest.fn().mockRejectedValue(new Error("permanent failure")); + await tracker + .submit(action({ orderId: "bad" }), doomed) + .catch(() => undefined); + + const stats = tracker.getStats(); + expect(stats.tracked).toBe(2); + expect(stats.succeeded).toBe(1); + expect(stats.failed).toBe(1); + expect(stats.retries).toBeGreaterThanOrEqual(1); + + const badRecord = tracker.getRecord(action({ orderId: "bad" })); + expect(badRecord?.attempts).toBe(3); + expect(badRecord?.lastError).toBe("permanent failure"); + }); + + it("throws RelayTimeoutError-shaped errors that the retry policy treats as retryable", async () => { + const tracker = new RelaySubmissionTracker({ + maxAttempts: 1, + timeoutMs: 5, + sleep: noSleep, + }); + let captured: unknown; + const executor = () => new Promise(() => {}); + await tracker.submit(action(), executor).catch((e) => (captured = e)); + // With a budget of 1, the timeout becomes the terminal error. + expect(captured).toBeInstanceOf(RelayTerminalError); + expect((captured as RelayTerminalError).lastError).toMatch(/timed out/i); + // Sanity: a raw timeout is its own error type. + expect(new RelayTimeoutError("x")).toBeInstanceOf(Error); + }); +}); From 79f2af34f6f265865101f9b77d9962644b9cfc85 Mon Sep 17 00:00:00 2001 From: victor-134 Date: Thu, 2 Jul 2026 06:34:13 +0100 Subject: [PATCH 4/4] Fix trailing whitespace and line endings in docs --- ROADMAP.md | 1 - docs/DILIGENCE_DATA_ROOM.md | 6 +++--- docs/ECOSYSTEM_INTEGRATION.md | 4 ++-- docs/GOVERNANCE_PATH.md | 4 ++-- docs/KPI_DASHBOARD_SPEC.md | 8 ++++---- docs/SCF_EVIDENCE.md | 34 +++++++++++++++++----------------- 6 files changed, 28 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d2fe4cf..0f493f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,4 +131,3 @@ demand from integrators after mainnet. ## Open dependencies and risks For a comprehensive tracking of external blockers, audit schedules, resolver coldstart incentives, RPC dependencies, and tooling status, see the detailed [Roadmap Dependency Tracker](docs/ROADMAP_DEPENDENCIES.md). - diff --git a/docs/DILIGENCE_DATA_ROOM.md b/docs/DILIGENCE_DATA_ROOM.md index e3c7e88..7aa0e3a 100644 --- a/docs/DILIGENCE_DATA_ROOM.md +++ b/docs/DILIGENCE_DATA_ROOM.md @@ -1,7 +1,7 @@ # OverSync v2 — SCF Diligence Data Room -> **Audience:** Stellar Community Fund (SCF) reviewers and prospective investors. -> **Reading time:** ≤ 10 minutes (use the section links below to jump to what you need). +> **Audience:** Stellar Community Fund (SCF) reviewers and prospective investors. +> **Reading time:** ≤ 10 minutes (use the section links below to jump to what you need). > **Status (June 2026):** v2 is live on Sepolia + Stellar testnet. Mainnet is intentionally gated on independent audits (target: Q1 2027). No protocol behaviour changed in this document — it is documentation only. --- @@ -25,7 +25,7 @@ ## 1. Live testnet contracts -All addresses are sourced directly from [`deployments.testnet.json`](../deployments.testnet.json). +All addresses are sourced directly from [`deployments.testnet.json`](../deployments.testnet.json). No mainnet contracts are active in the v2 UI (`VITE_MAINNET_ENABLED=false`). ### Ethereum — Sepolia (chain ID 11155111) diff --git a/docs/ECOSYSTEM_INTEGRATION.md b/docs/ECOSYSTEM_INTEGRATION.md index 2e6c312..438e181 100644 --- a/docs/ECOSYSTEM_INTEGRATION.md +++ b/docs/ECOSYSTEM_INTEGRATION.md @@ -18,9 +18,9 @@ OverSync differs fundamentally by avoiding wrapping and validator committees ent ## Adapter Isolation and Feature Flagging -As we build adapters to interact with tools like CCTP or Axelar, it is critical that the core HTLC logic remains strictly isolated. +As we build adapters to interact with tools like CCTP or Axelar, it is critical that the core HTLC logic remains strictly isolated. -**Rule:** Every future ecosystem adapter must be deployed behind an explicit feature flag or launch gate. +**Rule:** Every future ecosystem adapter must be deployed behind an explicit feature flag or launch gate. - Core HTLC settlement must not depend on the availability or security of external bridge contracts. - Experimental integrations must not put mainnet funds at risk if a third-party bridge encounters downtime. - Adapters should be treated as composable modules rather than embedded core logic. diff --git a/docs/GOVERNANCE_PATH.md b/docs/GOVERNANCE_PATH.md index 80d514a..9cf6485 100644 --- a/docs/GOVERNANCE_PATH.md +++ b/docs/GOVERNANCE_PATH.md @@ -19,7 +19,7 @@ All addresses below are from [`deployments.testnet.json`](../deployments.testnet | Stellar testnet | `HTLC` | `CDIKSJKVMXKGBRD3BBEBMF7Q4GQJ52ECU6R6G5HEKXKXVGGWK2CTA6JK` | [`soroban/contracts/htlc/src/lib.rs`](../soroban/contracts/htlc/src/lib.rs) | | Stellar testnet | `ResolverRegistry` | `CBSR7Z4MHLPMLFFM5K3PK3YLZAVCOMJ4KPVRWO4VPL3FF64MSTIZ4WGF` | [`soroban/contracts/resolver-registry/src/lib.rs`](../soroban/contracts/resolver-registry/src/lib.rs) | -**Testnet deployer EOA (EVM):** `0x686Be1DEF4b9Bd725A5Df07505E25a94Fa71394c` +**Testnet deployer EOA (EVM):** `0x686Be1DEF4b9Bd725A5Df07505E25a94Fa71394c` **Testnet deployer account (Stellar):** `GC4VWBK5QSJCBSRWIZJYWCF2SJAPCKU3OFHH4XK7ZBTZ5HCK7VYLU6FL` > The legacy v1 contracts (`HTLCBridge.sol`, `EscrowFactory.sol`, @@ -84,7 +84,7 @@ user funds."* **Admin role: `Admin` key in instance storage** -Current holder: `GC4VWBK5QSJCBSRWIZJYWCF2SJAPCKU3OFHH4XK7ZBTZ5HCK7VYLU6FL` +Current holder: `GC4VWBK5QSJCBSRWIZJYWCF2SJAPCKU3OFHH4XK7ZBTZ5HCK7VYLU6FL` Config at deploy: `minStake = 1 000 000 000 stroops (100 XLM)`, `slashBeneficiary = deployer address` (see [`deployments.testnet.json`](../deployments.testnet.json) §`resolverRegistryConfig`). diff --git a/docs/KPI_DASHBOARD_SPEC.md b/docs/KPI_DASHBOARD_SPEC.md index d672037..133b36a 100644 --- a/docs/KPI_DASHBOARD_SPEC.md +++ b/docs/KPI_DASHBOARD_SPEC.md @@ -1,6 +1,6 @@ # OverSync KPI Dashboard Specification -This document defines the overarching narrative, decision thresholds, and key performance indicators (KPIs) required to determine our testnet traction and mainnet launch readiness. +This document defines the overarching narrative, decision thresholds, and key performance indicators (KPIs) required to determine our testnet traction and mainnet launch readiness. It explicitly maps to our canonical [Public Metrics Schema](./METRICS_SCHEMA.md) and our [User Adoption Experiments](./ADOPTION_EXPERIMENTS.md). @@ -18,7 +18,7 @@ It explicitly maps to our canonical [Public Metrics Schema](./METRICS_SCHEMA.md) - **Source:** Coordinator logs (`/metrics` endpoint) - **Cadence:** Daily - **Owner:** Core Team (Backend) -- **Thresholds:** +- **Thresholds:** - 🟢 **Green (Launch Ready):** > 98% - 🟡 **Yellow (Investigate):** 90% - 98% - 🔴 **Red (Blocker):** < 90% @@ -86,7 +86,7 @@ It explicitly maps to our canonical [Public Metrics Schema](./METRICS_SCHEMA.md) - **Owner:** Core Team (Smart Contracts) - **Thresholds:** - 🟢 **Green (Launch Ready):** 0% - - 🟡 **Yellow (Investigate):** N/A + - 🟡 **Yellow (Investigate):** N/A - 🔴 **Red (Blocker):** > 0% --- @@ -114,7 +114,7 @@ It explicitly maps to our canonical [Public Metrics Schema](./METRICS_SCHEMA.md) - **Owner:** Core Team (Security Lead) - **Thresholds:** - 🟢 **Green (Launch Ready):** 0 - - 🟡 **Yellow (Investigate):** N/A + - 🟡 **Yellow (Investigate):** N/A - 🔴 **Red (Blocker):** > 0 --- diff --git a/docs/SCF_EVIDENCE.md b/docs/SCF_EVIDENCE.md index ba55d0a..9839447 100644 --- a/docs/SCF_EVIDENCE.md +++ b/docs/SCF_EVIDENCE.md @@ -103,7 +103,7 @@ Genuine traction narrative supported by verifiable evidence rather than vanity m #### 3.1 User Segments Docs/TRACTION.md defines 4 priority user segments: - Trust-conscious power users -- Stellar-native protocols seeking ETH liquidity +- Stellar-native protocols seeking ETH liquidity - 1inch Fusion+ resolver operators - Treasuries and OTC desks @@ -156,51 +156,51 @@ pnpm run test:e2e # differential tests (if available) Document what changed since the rejected v1 attempt and where each fix lives. #### 5.1 Operator Model Change -**Before:** Single privileged relayer with hot keys -**After:** Open resolver registry with stake + slash +**Before:** Single privileged relayer with hot keys +**After:** Open resolver registry with stake + slash **Where:** - Registry contracts: `contracts/v2/ResolverRegistry.sol` + `soroban/contracts/resolver-registry` - Resolver runner: `resolver/` Docker image + `docs/RESOLVERS.md` - Evidence: `docs/REVIEW_RESPONSE.md` §§1, 8, 157-159 #### 5.2 Stellar Settlement Change -**Before:** Stellar claimable balance with unconditional claimants -**After:** Native Soroban HTLC contract with sha256 hashlock + timelock +**Before:** Stellar claimable balance with unconditional claimants +**After:** Native Soroban HTLC contract with sha256 hashlock + timelock **Where:** - New contract: `soroban/contracts/htlc/src/lib.rs` - 10 unit tests covering happy path, refunds, double claims - Evidence: `docs/REVIEW_RESPONSE.md` §2 #### 5.3 Refund Path Change -**Before:** Mocked refunds (`relayer/src/recovery-service.ts:364-371`) -**After:** Permissionless on-chain refunds +**Before:** Mocked refunds (`relayer/src/recovery-service.ts:364-371`) +**After:** Permissionless on-chain refunds **Where:** - EVM: `HTLCEscrow.refundOrder` function -- Stellar: `oversync-htlc::refund_order` function +- Stellar: `oversync-htlc::refund_order` function - Frontend: `RefundDialog` component - Evidence: `docs/REVIEW_RESPONSE.md` §6 #### 5.4 Data Integrity Change -**Before:** Fake `0x1234567890abcdef` style transactions in history -**After:** All fake/mock data removed; only real on-chain events +**Before:** Fake `0x1234567890abcdef` style transactions in history +**After:** All fake/mock data removed; only real on-chain events **Where:** - Frontend: `TransactionHistory.tsx` with `isRealHash` filter - Relayer: No mock data in `websocket-server.ts` or `index.ts` - Evidence: `docs/REVIEW_RESPONSE.md` §7 #### 5.5 Documentation Change -**Before:** Inconsistent docs (`MAINNET_SETUP.md`, `env.example` duplicate) -**After:** Consolidated into `docs/DEPLOYMENT.md` +**Before:** Inconsistent docs (`MAINNET_SETUP.md`, `env.example` duplicate) +**After:** Consolidated into `docs/DEPLOYMENT.md` **Where:** Entire `docs/DEPLOYMENT.md` file #### 5.6 Code Quality Change -**Before:** Monolithic v1 relayer (3,276 lines) -**After:** Modular v2 coordinator (<200 lines) +**Before:** Monolithic v1 relayer (3,276 lines) +**After:** Modular v2 coordinator (<200 lines) **Where:** `coordinator/` directory #### 5.7 Budget Realignment -**Before:** $30K broad request -**After:** $40K tranche-gated request +**Before:** $30K broad request +**After:** $40K tranche-gated request **Where:** `docs/REVIEW_RESPONSE.md` §167-182 --- @@ -243,7 +243,7 @@ Every major claim has an evidence link or an explicit "not yet shipped" status. #### 8.1 Stellar/Soroban Usage - [x] Soroban HTLC contract deployed and testable -- [x] Soroban resolver registry deployed and testable +- [x] Soroban resolver registry deployed and testable - [x] Testnet contract IDs published - [x] SDK Soroban integration completed - [x] Freighter wallet flow implemented