From f1da195a6418b970eb99fe74eb75b5ab6f800766 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 18:53:18 +0000 Subject: [PATCH 1/3] Include triggering order requestId/quoteId in circuit breaker block notification Fillers blocked by the circuit breaker receive a notification containing only blockUntilTimestamp, so they cannot tell which order triggered it. Thread the QuoteRequest into WebhookQuoter.notifyBlock and include the order's requestId (and quoteId when present, e.g. hard quotes) in the notification payload. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018oU7bRX2dPnye7vC54t3Uz --- lib/quoters/WebhookQuoter.ts | 11 ++++-- test/providers/quoters/WebhookQuoter.test.ts | 37 +++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/lib/quoters/WebhookQuoter.ts b/lib/quoters/WebhookQuoter.ts index 58b27c38..64cb3e9c 100644 --- a/lib/quoters/WebhookQuoter.ts +++ b/lib/quoters/WebhookQuoter.ts @@ -72,7 +72,7 @@ export class WebhookQuoter implements Quoter { // should not await and block if (!isPermissionedToken) { - Promise.allSettled(disabledEndpoints.map((e) => this.notifyBlock(e))).then((results) => { + Promise.allSettled(disabledEndpoints.map((e) => this.notifyBlock(e, request))).then((results) => { this.log.info({ requestId: request.requestId, results }, 'Notified disabled endpoints'); }); } @@ -376,7 +376,10 @@ export class WebhookQuoter implements Quoter { } } - private async notifyBlock(status: { webhook: WebhookConfiguration; blockUntil: number }): Promise { + private async notifyBlock( + status: { webhook: WebhookConfiguration; blockUntil: number }, + request: QuoteRequest + ): Promise { const axiosConfig = { timeout: NOTIFICATION_TIMEOUT_MS, ...(!!status.webhook.headers && { headers: status.webhook.headers }), @@ -386,6 +389,10 @@ export class WebhookQuoter implements Quoter { status.webhook.endpoint, { blockUntilTimestamp: status.blockUntil, + // Identify the order that triggered this notification so blocked fillers + // can tell which order they were excluded from quoting. + requestId: request.requestId, + ...(request.quoteId && { quoteId: request.quoteId }), }, axiosConfig ) diff --git a/test/providers/quoters/WebhookQuoter.test.ts b/test/providers/quoters/WebhookQuoter.test.ts index 3f0ef1cb..88546327 100644 --- a/test/providers/quoters/WebhookQuoter.test.ts +++ b/test/providers/quoters/WebhookQuoter.test.ts @@ -369,6 +369,41 @@ describe('WebhookQuoter tests', () => { WEBHOOK_URL_ONEINCH, { blockUntilTimestamp: expect.any(Number), + requestId: REQUEST_ID, + }, + { + headers: {}, + timeout: NOTIFICATION_TIMEOUT_MS, + } + ); + }); + + it('includes the triggering order quoteId in block notification when present', async () => { + mockedAxios.post + .mockImplementationOnce((_endpoint, _req, _options) => { + return Promise.resolve({ + data: { ...quote, requestId: (_req as any).requestId }, + }); + }) + .mockImplementationOnce((_endpoint, _req, _options) => { + return Promise.resolve({ + data: { + ...quote, + tokenIn: request.tokenOut, + tokenOut: request.tokenIn, + }, + }); + }); + + // hard quote requests carry the order's quoteId + const requestWithQuoteId = makeQuoteRequest({ quoteId: QUOTE_ID }); + await webhookQuoter.quote(requestWithQuoteId); + expect(mockedAxios.post).toBeCalledWith( + WEBHOOK_URL_ONEINCH, + { + blockUntilTimestamp: expect.any(Number), + requestId: REQUEST_ID, + quoteId: QUOTE_ID, }, { headers: {}, @@ -560,7 +595,7 @@ describe('WebhookQuoter tests', () => { // blocked expect(mockedAxios.post).toBeCalledWith( WEBHOOK_URL_ONEINCH, - { blockUntilTimestamp: expect.any(Number) }, + { blockUntilTimestamp: expect.any(Number), requestId: REQUEST_ID }, { headers: {}, timeout: NOTIFICATION_TIMEOUT_MS } ); expect(mockedAxios.post).toBeCalledWith( From a582a2066f9e5f471120229886d8c06ffee84ae6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 19:03:41 +0000 Subject: [PATCH 2/3] Use order hash instead of requestId in circuit breaker block notification Per review feedback, the block notification now identifies the triggering order by its on-chain order hash: HardQuoteRequest.toQuoteRequest() threads order.hash() onto the QuoteRequest (internal only; excluded from wire payloads), and notifyBlock sends { orderHash, quoteId } for hard quotes. Soft quotes have no signed order at quote time, so they fall back to requestId to keep an identifier in the notification. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018oU7bRX2dPnye7vC54t3Uz --- lib/entities/HardQuoteRequest.ts | 1 + lib/entities/QuoteRequest.ts | 7 +++++++ lib/quoters/WebhookQuoter.ts | 6 ++++-- test/entities/HardQuoteRequest.test.ts | 16 ++++++++++++++++ test/providers/quoters/WebhookQuoter.test.ts | 13 ++++++++----- 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/lib/entities/HardQuoteRequest.ts b/lib/entities/HardQuoteRequest.ts index 99ad738c..d4dd027a 100644 --- a/lib/entities/HardQuoteRequest.ts +++ b/lib/entities/HardQuoteRequest.ts @@ -70,6 +70,7 @@ export class HardQuoteRequest { swapper: this.swapper, amount: this.amount, type: this.type, + orderHash: this.order.hash(), }); } diff --git a/lib/entities/QuoteRequest.ts b/lib/entities/QuoteRequest.ts index 37e9881d..062f8c47 100644 --- a/lib/entities/QuoteRequest.ts +++ b/lib/entities/QuoteRequest.ts @@ -17,6 +17,9 @@ export interface QuoteRequestData { numOutputs: number; protocol: ProtocolVersion; quoteId?: string; + // hash of the signed order (hard quotes only; soft quotes have no order yet). + // Kept internal — deliberately excluded from the toJSON/toCleanJSON wire payloads. + orderHash?: string; } export interface QuoteRequestDataJSON extends Omit { @@ -152,4 +155,8 @@ export class QuoteRequest { public set quoteId(quoteId: string | undefined) { this.data.quoteId = quoteId; } + + public get orderHash(): string | undefined { + return this.data.orderHash; + } } diff --git a/lib/quoters/WebhookQuoter.ts b/lib/quoters/WebhookQuoter.ts index 64cb3e9c..1d5de95a 100644 --- a/lib/quoters/WebhookQuoter.ts +++ b/lib/quoters/WebhookQuoter.ts @@ -390,8 +390,10 @@ export class WebhookQuoter implements Quoter { { blockUntilTimestamp: status.blockUntil, // Identify the order that triggered this notification so blocked fillers - // can tell which order they were excluded from quoting. - requestId: request.requestId, + // can tell which order they were excluded from quoting. Soft quotes carry + // no signed order (so no order hash); fall back to the requestId there so + // the notification still carries an identifier. + ...(request.orderHash ? { orderHash: request.orderHash } : { requestId: request.requestId }), ...(request.quoteId && { quoteId: request.quoteId }), }, axiosConfig diff --git a/test/entities/HardQuoteRequest.test.ts b/test/entities/HardQuoteRequest.test.ts index 11b2284a..1bf255be 100644 --- a/test/entities/HardQuoteRequest.test.ts +++ b/test/entities/HardQuoteRequest.test.ts @@ -171,6 +171,22 @@ describe('QuoteRequest', () => { }); }); + it('toQuoteRequest carries the order hash', async () => { + const order = new UnsignedV2DutchOrder( + getOrderInfo({ + swapper: SWAPPER, + }), + CHAIN_ID + ); + const request = makeRequest({ encodedInnerOrder: order.serialize(), innerSig: '0x' }); + const quoteRequest = request.toQuoteRequest(); + expect(quoteRequest.orderHash).toEqual(order.hash()); + expect(quoteRequest.quoteId).toEqual(QUOTE_ID); + // the order hash is internal only — it must not leak into the wire payloads + expect(quoteRequest.toJSON()).not.toHaveProperty('orderHash'); + expect(quoteRequest.toCleanJSON()).not.toHaveProperty('orderHash'); + }); + it('exposes protocol v2 for Dutch_V2 orders', () => { const order = new UnsignedV2DutchOrder( getOrderInfo({ diff --git a/test/providers/quoters/WebhookQuoter.test.ts b/test/providers/quoters/WebhookQuoter.test.ts index 88546327..856bf1c0 100644 --- a/test/providers/quoters/WebhookQuoter.test.ts +++ b/test/providers/quoters/WebhookQuoter.test.ts @@ -24,6 +24,7 @@ const mockedAxios = axios as jest.Mocked; const QUOTE_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; const REQUEST_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; +const ORDER_HASH = '0x1111111111111111111111111111111111111111111111111111111111111111'; const SWAPPER = '0x0000000000000000000000000000000000000000'; const TOKEN_IN = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'; const TOKEN_OUT = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; @@ -364,6 +365,7 @@ describe('WebhookQuoter tests', () => { }); }); + // soft quote: no signed order exists yet, so the notification falls back to requestId await webhookQuoter.quote(request); expect(mockedAxios.post).toBeCalledWith( WEBHOOK_URL_ONEINCH, @@ -378,7 +380,7 @@ describe('WebhookQuoter tests', () => { ); }); - it('includes the triggering order quoteId in block notification when present', async () => { + it('includes the triggering order hash and quoteId in block notification when present', async () => { mockedAxios.post .mockImplementationOnce((_endpoint, _req, _options) => { return Promise.resolve({ @@ -395,14 +397,15 @@ describe('WebhookQuoter tests', () => { }); }); - // hard quote requests carry the order's quoteId - const requestWithQuoteId = makeQuoteRequest({ quoteId: QUOTE_ID }); - await webhookQuoter.quote(requestWithQuoteId); + // hard quote requests carry the signed order's hash and quoteId + const hardQuoteRequest = makeQuoteRequest({ orderHash: ORDER_HASH, quoteId: QUOTE_ID }); + await webhookQuoter.quote(hardQuoteRequest); + // exact payload match: requestId must not be included when the order hash is present expect(mockedAxios.post).toBeCalledWith( WEBHOOK_URL_ONEINCH, { blockUntilTimestamp: expect.any(Number), - requestId: REQUEST_ID, + orderHash: ORDER_HASH, quoteId: QUOTE_ID, }, { From 6d6149985376314c5d314ff2d8c6bfec9bdd85c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 19:23:46 +0000 Subject: [PATCH 3/3] Send the faded order hashes that caused a circuit breaker block in the notification Per review, the block notification should name the order(s) whose fades caused the block, not the order currently being quoted. The fade-rate-v2 cron now selects orderHash from the latestRfqsV2 view (verified against posted_orders.yaml in data-eng-workflows), collects the hashes of each filler's new faded orders, and persists them on the block entry in Dynamo (additive fadedOrderHashes list attribute; capped, deduped, carried forward while the block is active, cleared when it expires). The circuit breaker provider surfaces them on disabled endpoints and notifyBlock sends { blockUntilTimestamp, orderHashes, quoteId? }. requestId is gone from the payload, and legacy block entries without stored hashes simply omit orderHashes. The previous threading of the current request's order hash through QuoteRequest is reverted as no longer needed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018oU7bRX2dPnye7vC54t3Uz --- lib/constants.ts | 1 + lib/cron/fade-rate-v2.ts | 65 ++++++++-- lib/entities/HardQuoteRequest.ts | 1 - lib/entities/QuoteRequest.ts | 7 -- lib/providers/circuit-breaker/dynamo.ts | 1 + lib/providers/circuit-breaker/index.ts | 3 + lib/providers/circuit-breaker/mock.ts | 1 + lib/quoters/WebhookQuoter.ts | 11 +- lib/repositories/base.ts | 4 + lib/repositories/fades-repository.ts | 18 +-- lib/repositories/timestamp-repository.ts | 6 + test/crons/fade-rate-v2.test.ts | 116 ++++++++++++++++++ test/entities/HardQuoteRequest.test.ts | 16 --- test/fixtures.ts | 15 ++- .../circuit-breaker/cb-provider.test.ts | 12 +- test/providers/quoters/WebhookQuoter.test.ts | 53 ++++++-- .../repositories/timestamp-repository.test.ts | 5 + 17 files changed, 279 insertions(+), 56 deletions(-) diff --git a/lib/constants.ts b/lib/constants.ts index a1a278f9..53e7f042 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -32,6 +32,7 @@ export const DYNAMO_TABLE_KEY = { LAST_POST_TIMESTAMP: 'lastPostTimestamp', FADED: 'faded', CONSECUTIVE_BLOCKS: 'consecutiveBlocks', + FADED_ORDER_HASHES: 'fadedOrderHashes', }; export const POST_ORDER_ERROR_REASON = { diff --git a/lib/cron/fade-rate-v2.ts b/lib/cron/fade-rate-v2.ts index ee0e4885..792a7e0f 100644 --- a/lib/cron/fade-rate-v2.ts +++ b/lib/cron/fade-rate-v2.ts @@ -22,11 +22,16 @@ import { TimestampRepository } from '../repositories/timestamp-repository'; import { STAGE } from '../util/stage'; export type FillerFades = Record; +export type FillerFadedOrderHashes = Record; export type FillerTimestamps = Map>; export const BASE_BLOCK_SECS = 60 * 15; // 15 minutes export const NUM_FADES_MULTIPLIER = 1.2; +/** Bound on the faded order hashes persisted per block entry (most recent kept), so + repeatedly extended blocks can't grow the Dynamo item without limit. */ +export const MAX_FADED_ORDER_HASHES = 50; + /** Sentinel when the filler has no active block (always < now for real unix seconds). Avoids equaling lastPostTimestamp, which could briefly read as blocked under clock skew. */ export const UNBLOCKED_BLOCK_UNTIL_TIMESTAMP = 0; @@ -85,6 +90,8 @@ async function main(metrics: MetricsLogger) { // |---- foo -|---- 3 ----|---- 12345678 ----| // |---- bar -|---- 1 ----|---- 12222222 ----| const fillersNewFades = getFillersNewFades(result, addressToFillerMap, fillerTimestamps, log); + // the hashes of those faded orders, so block entries can carry the reason for the block + const fillersFadedOrderHashes = getFillersNewFadedOrderHashes(result, addressToFillerMap, fillerTimestamps); // | hash |lastPostTimestamp|blockUntilTimestamp| // |---- foo ----|---- 1300000 ----|---- calculated block until ----| @@ -94,7 +101,8 @@ async function main(metrics: MetricsLogger) { fillersNewFades, Math.floor(Date.now() / 1000), log, - metrics + metrics, + fillersFadedOrderHashes ); log.info({ updatedTimestamps }, 'filler for which to update timestamp'); metrics.putMetric(Metric.CIRCUIT_BREAKER_V2_BLOCKED, updatedTimestamps.length, Unit.Count); @@ -129,7 +137,8 @@ export function calculateNewTimestamps( fillersNewFades: FillerFades, newPostTimestamp: number, log?: Logger, - metrics?: MetricsLogger + metrics?: MetricsLogger, + fillersFadedOrderHashes?: FillerFadedOrderHashes ): ToUpdateTimestampRow[] { const updatedTimestamps: ToUpdateTimestampRow[] = []; Object.entries(fillersNewFades).forEach((row) => { @@ -137,6 +146,7 @@ export function calculateNewTimestamps( const fades = row[1]; const fillerTimestamp = fillerTimestamps.get(hash); const isCurrentlyBlocked = fillerTimestamp && fillerTimestamp.blockUntilTimestamp > newPostTimestamp; + const newFadedOrderHashes = fillersFadedOrderHashes?.[hash] ?? []; if (isCurrentlyBlocked && fades) { // Stack penalties while blocked @@ -163,6 +173,8 @@ export function calculateNewTimestamps( lastPostTimestamp: newPostTimestamp, blockUntilTimestamp: extendedBlockUntil, consecutiveBlocks, + // the extended block was caused by the previously stored fades plus the new ones + fadedOrderHashes: mergeFadedOrderHashes(fillerTimestamp.fadedOrderHashes, newFadedOrderHashes), }); } else if (isCurrentlyBlocked) { // Blocked but no new fades - keep existing block, don't decay @@ -171,6 +183,8 @@ export function calculateNewTimestamps( lastPostTimestamp: newPostTimestamp, blockUntilTimestamp: fillerTimestamp.blockUntilTimestamp, consecutiveBlocks: fillerTimestamp.consecutiveBlocks, + // carry forward the fades that caused the still-active block + fadedOrderHashes: fillerTimestamp.fadedOrderHashes, }); } else if (fades) { const blockUntilTimestamp = calculateBlockUntilTimestamp( @@ -190,6 +204,7 @@ export function calculateNewTimestamps( lastPostTimestamp: newPostTimestamp, blockUntilTimestamp, consecutiveBlocks: consecutiveBlocks, + fadedOrderHashes: mergeFadedOrderHashes(undefined, newFadedOrderHashes), }); } else { // no new fades, decay consecutive blocks gradually instead of resetting @@ -237,12 +252,7 @@ export function getFillersNewFades( const fillerHash = addressToFillerMap.get(fillerAddr); if (!fillerHash) { log?.info({ fillerAddr }, 'filler address not found dynamo mapping'); - } else if ( - // Use deadline (completion time) instead of postTimestamp to catch orders - // that were posted before lastPostTimestamp but completed after - (fillerTimestamps.has(fillerHash) && row.deadline > fillerTimestamps.get(fillerHash)!.lastPostTimestamp) || - !fillerTimestamps.has(fillerHash) - ) { + } else if (isNewRow(row, fillerHash, fillerTimestamps)) { if (!newFadesMap[fillerHash]) { newFadesMap[fillerHash] = row.faded; } else { @@ -254,6 +264,45 @@ export function getFillersNewFades( return newFadesMap; } +/* collect the hashes of each filler entity's new faded orders since the last time + this cron was run; same "new order" semantics as getFillersNewFades */ +export function getFillersNewFadedOrderHashes( + rows: V2FadesRowType[], + addressToFillerMap: Map, + fillerTimestamps: FillerTimestamps +): FillerFadedOrderHashes { + const fadedOrderHashesMap: FillerFadedOrderHashes = {}; // filler hash -> hashes of new faded orders + rows.forEach((row) => { + const fillerAddr = ethers.utils.getAddress(row.fillerAddress); + const fillerHash = addressToFillerMap.get(fillerAddr); + if (fillerHash && row.faded && row.orderHash && isNewRow(row, fillerHash, fillerTimestamps)) { + if (!fadedOrderHashesMap[fillerHash]) { + fadedOrderHashesMap[fillerHash] = []; + } + fadedOrderHashesMap[fillerHash].push(row.orderHash); + } + }); + return fadedOrderHashesMap; +} + +/* returns true if the order completed after the filler's last checked timestamp. + Use deadline (completion time) instead of postTimestamp to catch orders + that were posted before lastPostTimestamp but completed after */ +function isNewRow(row: V2FadesRowType, fillerHash: string, fillerTimestamps: FillerTimestamps): boolean { + return ( + (fillerTimestamps.has(fillerHash) && row.deadline > fillerTimestamps.get(fillerHash)!.lastPostTimestamp) || + !fillerTimestamps.has(fillerHash) + ); +} + +/* combine the faded order hashes already stored on a block entry with the new window's, + deduped and capped to the most recent MAX_FADED_ORDER_HASHES. + Returns undefined when there are none, so no attribute is written to the entry. */ +function mergeFadedOrderHashes(existing: string[] | undefined, incoming: string[]): string[] | undefined { + const merged = Array.from(new Set([...(existing ?? []), ...incoming])); + return merged.length > 0 ? merged.slice(-MAX_FADED_ORDER_HASHES) : undefined; +} + /* calculate the block until timestamp with exponential backoff if a filler faded multiple times in between the last post timestamp and now, diff --git a/lib/entities/HardQuoteRequest.ts b/lib/entities/HardQuoteRequest.ts index d4dd027a..99ad738c 100644 --- a/lib/entities/HardQuoteRequest.ts +++ b/lib/entities/HardQuoteRequest.ts @@ -70,7 +70,6 @@ export class HardQuoteRequest { swapper: this.swapper, amount: this.amount, type: this.type, - orderHash: this.order.hash(), }); } diff --git a/lib/entities/QuoteRequest.ts b/lib/entities/QuoteRequest.ts index 062f8c47..37e9881d 100644 --- a/lib/entities/QuoteRequest.ts +++ b/lib/entities/QuoteRequest.ts @@ -17,9 +17,6 @@ export interface QuoteRequestData { numOutputs: number; protocol: ProtocolVersion; quoteId?: string; - // hash of the signed order (hard quotes only; soft quotes have no order yet). - // Kept internal — deliberately excluded from the toJSON/toCleanJSON wire payloads. - orderHash?: string; } export interface QuoteRequestDataJSON extends Omit { @@ -155,8 +152,4 @@ export class QuoteRequest { public set quoteId(quoteId: string | undefined) { this.data.quoteId = quoteId; } - - public get orderHash(): string | undefined { - return this.data.orderHash; - } } diff --git a/lib/providers/circuit-breaker/dynamo.ts b/lib/providers/circuit-breaker/dynamo.ts index b56e5407..11dca6a3 100644 --- a/lib/providers/circuit-breaker/dynamo.ts +++ b/lib/providers/circuit-breaker/dynamo.ts @@ -75,6 +75,7 @@ export class DynamoCircuitBreakerConfigurationProvider implements CircuitBreaker return { webhook: e, blockUntil: fillerTimestamps.get(e.endpoint)!.blockUntilTimestamp, + fadedOrderHashes: fillerTimestamps.get(e.endpoint)!.fadedOrderHashes, }; }); diff --git a/lib/providers/circuit-breaker/index.ts b/lib/providers/circuit-breaker/index.ts index 41ac2840..b57a291c 100644 --- a/lib/providers/circuit-breaker/index.ts +++ b/lib/providers/circuit-breaker/index.ts @@ -12,6 +12,9 @@ export interface EndpointStatuses { disabled: { webhook: WebhookConfiguration; blockUntil: number; + // hashes of the faded orders that caused the block; + // absent on block entries written before hashes were persisted + fadedOrderHashes?: string[]; }[]; } diff --git a/lib/providers/circuit-breaker/mock.ts b/lib/providers/circuit-breaker/mock.ts index 68e06398..f9fd0cc2 100644 --- a/lib/providers/circuit-breaker/mock.ts +++ b/lib/providers/circuit-breaker/mock.ts @@ -24,6 +24,7 @@ export class MockV2CircuitBreakerConfigurationProvider implements CircuitBreaker return { webhook: e, blockUntil: fillerTimestamps.get(e.endpoint)!.blockUntilTimestamp, + fadedOrderHashes: fillerTimestamps.get(e.endpoint)!.fadedOrderHashes, }; }); diff --git a/lib/quoters/WebhookQuoter.ts b/lib/quoters/WebhookQuoter.ts index 1d5de95a..db8c1b34 100644 --- a/lib/quoters/WebhookQuoter.ts +++ b/lib/quoters/WebhookQuoter.ts @@ -377,7 +377,7 @@ export class WebhookQuoter implements Quoter { } private async notifyBlock( - status: { webhook: WebhookConfiguration; blockUntil: number }, + status: { webhook: WebhookConfiguration; blockUntil: number; fadedOrderHashes?: string[] }, request: QuoteRequest ): Promise { const axiosConfig = { @@ -389,11 +389,10 @@ export class WebhookQuoter implements Quoter { status.webhook.endpoint, { blockUntilTimestamp: status.blockUntil, - // Identify the order that triggered this notification so blocked fillers - // can tell which order they were excluded from quoting. Soft quotes carry - // no signed order (so no order hash); fall back to the requestId there so - // the notification still carries an identifier. - ...(request.orderHash ? { orderHash: request.orderHash } : { requestId: request.requestId }), + // The faded order(s) that caused this block, so fillers know exactly which + // orders triggered the circuit breaker. Omitted for block entries written + // before the faded order hashes were persisted alongside the block. + ...(!!status.fadedOrderHashes?.length && { orderHashes: status.fadedOrderHashes }), ...(request.quoteId && { quoteId: request.quoteId }), }, axiosConfig diff --git a/lib/repositories/base.ts b/lib/repositories/base.ts index 19ea3d72..529081bd 100644 --- a/lib/repositories/base.ts +++ b/lib/repositories/base.ts @@ -33,12 +33,16 @@ export type TimestampRepoRow = { lastPostTimestamp: number; blockUntilTimestamp: number; consecutiveBlocks: number; + // hashes of the faded orders that caused the current block; + // absent on rows written before this field existed + fadedOrderHashes?: string[]; }; export type DynamoTimestampRepoRow = Exclude & { lastPostTimestamp: string; blockUntilTimestamp: string; consecutiveBlocks: string; + fadedOrderHashes?: string[]; }; export type ToUpdateTimestampRow = Omit & { diff --git a/lib/repositories/fades-repository.ts b/lib/repositories/fades-repository.ts index 6dfaa3cd..b7c1aea1 100644 --- a/lib/repositories/fades-repository.ts +++ b/lib/repositories/fades-repository.ts @@ -18,6 +18,7 @@ export type V2FadesRowType = { faded: number; postTimestamp: number; deadline: number; // When the order outcome was finalized + orderHash: string; }; export class FadesRepository extends BaseRedshiftRepository { @@ -96,10 +97,10 @@ export class V2FadesRepository extends BaseRedshiftRepository { const stmtId = await this.executeStatement(V2_FADE_RATE_SQL, V2FadesRepository.log, { waitTimeMs: 2_000 }); const response = await this.client.send(new GetStatementResultCommand({ Id: stmtId })); /* result should be in the following format - | rfqFiller | postTimestamp | deadline | faded | - |---- bar ------|---- 12222222 ---|--- 12222282 -|---- 0 ---| - |---- foo ------|---- 12345679 ---|--- 12345739 -|---- 1 ---| - |---- foo ------|---- 12345678 ---|--- 12345738 -|---- 0 ---| + | rfqFiller | postTimestamp | deadline | faded | orderHash | + |---- bar ------|---- 12222222 ---|--- 12222282 -|---- 0 ---|---- 0xbar ---| + |---- foo ------|---- 12345679 ---|--- 12345739 -|---- 1 ---|---- 0xfoo ---| + |---- foo ------|---- 12345678 ---|--- 12345738 -|---- 0 ---|---- 0xbaz ---| */ const result = response.Records; if (!result) { @@ -113,6 +114,7 @@ export class V2FadesRepository extends BaseRedshiftRepository { postTimestamp: parseInt(row[1].stringValue as string), deadline: parseInt(row[2].stringValue as string), faded: Number(row[3].longValue as number), + orderHash: row[4].stringValue as string, }; return formattedRow; }); @@ -184,7 +186,7 @@ WITH latestOrdersV2 AS ( LIMIT 5000 ) SELECT - latestOrdersV2.chainid as chainId, latestOrdersV2.ordertype as orderType, latestOrdersV2.filler as rfqFiller, latestOrdersV2.startTime as decayStartTime, latestOrdersV2.quoteid, archivedorders.filler as actualFiller, latestOrdersV2.createdat as postTimestamp, latestOrdersV2.deadline as deadline, archivedorders.txhash as txHash, archivedOrders.fillTimestamp as fillTimestamp, archivedorders.fillTimeBlocks as fillTimeBlocks, archivedOrders.tokenIn as tokenIn, archivedOrders.tokenOut as tokenOut, + latestOrdersV2.chainid as chainId, latestOrdersV2.ordertype as orderType, latestOrdersV2.filler as rfqFiller, latestOrdersV2.startTime as decayStartTime, latestOrdersV2.quoteid, latestOrdersV2.orderhash as orderHash, archivedorders.filler as actualFiller, latestOrdersV2.createdat as postTimestamp, latestOrdersV2.deadline as deadline, archivedorders.txhash as txHash, archivedOrders.fillTimestamp as fillTimestamp, archivedorders.fillTimeBlocks as fillTimeBlocks, archivedOrders.tokenIn as tokenIn, archivedOrders.tokenOut as tokenOut, CASE WHEN latestOrdersV2.inputstartamount = latestOrdersV2.inputendamount THEN 'EXACT_INPUT' ELSE 'EXACT_OUTPUT' @@ -205,10 +207,11 @@ LIMIT 5000 // Exported for testing. export const V2_FADE_RATE_SQL = ` -SELECT +SELECT rfqFiller, postTimestamp, deadline, + -- the parser in getFades() indexes columns by position; keep this ordering in sync CASE -- Never filled (any order type) => fade. WHEN fillTimestamp IS NULL THEN 1 @@ -221,7 +224,8 @@ SELECT -- Dutch_V2 (time-based decay): filled after decay start => fade. WHEN orderType = '${OrderType.Dutch_V2}' AND decayStartTime < fillTimestamp THEN 1 ELSE 0 - END AS faded + END AS faded, + orderHash FROM latestRfqsV2 WHERE LOWER(tokenIn) NOT IN (${PERMISSIONED_TOKENS.map((token) => `'${token.address.toLowerCase()}'`).join(',')}) AND LOWER(tokenOut) NOT IN (${PERMISSIONED_TOKENS.map((token) => `'${token.address.toLowerCase()}'`).join(',')}) diff --git a/lib/repositories/timestamp-repository.ts b/lib/repositories/timestamp-repository.ts index 34a9f900..4cc2acff 100644 --- a/lib/repositories/timestamp-repository.ts +++ b/lib/repositories/timestamp-repository.ts @@ -34,6 +34,7 @@ export class TimestampRepository implements BaseTimestampRepository { [`${DYNAMO_TABLE_KEY.LAST_POST_TIMESTAMP}`]: { type: 'string' }, [`${DYNAMO_TABLE_KEY.BLOCK_UNTIL_TIMESTAMP}`]: { type: 'string' }, [`${DYNAMO_TABLE_KEY.CONSECUTIVE_BLOCKS}`]: { type: 'string' }, + [`${DYNAMO_TABLE_KEY.FADED_ORDER_HASHES}`]: { type: 'list' }, }, table: table, autoExecute: true, @@ -56,6 +57,7 @@ export class TimestampRepository implements BaseTimestampRepository { [`${DYNAMO_TABLE_KEY.LAST_POST_TIMESTAMP}`]: row.lastPostTimestamp, [`${DYNAMO_TABLE_KEY.BLOCK_UNTIL_TIMESTAMP}`]: row.blockUntilTimestamp, [`${DYNAMO_TABLE_KEY.CONSECUTIVE_BLOCKS}`]: row.consecutiveBlocks, + ...(row.fadedOrderHashes && { [`${DYNAMO_TABLE_KEY.FADED_ORDER_HASHES}`]: row.fadedOrderHashes }), }); }), { @@ -76,6 +78,8 @@ export class TimestampRepository implements BaseTimestampRepository { lastPostTimestamp: parseInt(Item?.lastPostTimestamp), blockUntilTimestamp: parseInt(Item?.blockUntilTimestamp), consecutiveBlocks: parseInt(Item?.consecutiveBlocks), + // absent on rows written before this attribute existed + fadedOrderHashes: Item?.fadedOrderHashes, }; } @@ -97,6 +101,7 @@ export class TimestampRepository implements BaseTimestampRepository { lastPostTimestamp: parseInt(row.lastPostTimestamp), blockUntilTimestamp: parseInt(row.blockUntilTimestamp), consecutiveBlocks: parseInt(row.consecutiveBlocks), + fadedOrderHashes: row.fadedOrderHashes, }; }); } @@ -109,6 +114,7 @@ export class TimestampRepository implements BaseTimestampRepository { lastPostTimestamp: row.lastPostTimestamp, blockUntilTimestamp: row.blockUntilTimestamp, consecutiveBlocks: row.consecutiveBlocks, + fadedOrderHashes: row.fadedOrderHashes, }); }); return res; diff --git a/test/crons/fade-rate-v2.test.ts b/test/crons/fade-rate-v2.test.ts index ed523b8e..b15ec90e 100644 --- a/test/crons/fade-rate-v2.test.ts +++ b/test/crons/fade-rate-v2.test.ts @@ -5,7 +5,9 @@ import { calculateNewTimestamps, FillerFades, FillerTimestamps, + getFillersNewFadedOrderHashes, getFillersNewFades, + MAX_FADED_ORDER_HASHES, NUM_FADES_MULTIPLIER, UNBLOCKED_BLOCK_UNTIL_TIMESTAMP, } from '../../lib/cron/fade-rate-v2'; @@ -22,24 +24,28 @@ const FADES_ROWS: V2FadesRowType[] = [ faded: 1, postTimestamp: now - 100, deadline: now - 80, + orderHash: '0xfade1a', }, { fillerAddress: '0x0000000000000000000000000000000000000001', faded: 0, postTimestamp: now - 90, deadline: now - 70, + orderHash: '0xok1', }, { fillerAddress: '0x0000000000000000000000000000000000000001', faded: 1, postTimestamp: now - 80, deadline: now - 60, + orderHash: '0xfade1b', }, { fillerAddress: '0x0000000000000000000000000000000000000002', faded: 1, postTimestamp: now - 80, deadline: now - 60, + orderHash: '0xfade1c', }, // filler2 - lastPostTimestamp: now - 75 // Order at now - 100 has deadline now - 80 which is NOT > now - 75, so NOT counted @@ -49,12 +55,14 @@ const FADES_ROWS: V2FadesRowType[] = [ faded: 1, postTimestamp: now - 70, deadline: now - 50, + orderHash: '0xfade2a', }, { fillerAddress: '0x0000000000000000000000000000000000000003', faded: 1, postTimestamp: now - 100, deadline: now - 80, + orderHash: '0xfade2old', }, // filler3 - lastPostTimestamp: now - 101, deadline now - 80 > now - 101, so counted // filler3 is BLOCKED (blockUntilTimestamp: now + 1000) and has a fade! @@ -63,6 +71,7 @@ const FADES_ROWS: V2FadesRowType[] = [ faded: 1, postTimestamp: now - 100, deadline: now - 80, + orderHash: '0xfade3a', }, // filler4 - lastPostTimestamp: now - 150, deadline now - 80 > now - 150, so counted { @@ -70,6 +79,7 @@ const FADES_ROWS: V2FadesRowType[] = [ faded: 0, postTimestamp: now - 100, deadline: now - 80, + orderHash: '0xok4', }, // filler5 - lastPostTimestamp: now - 150, deadline now - 80 > now - 150, so counted // filler5 is BLOCKED (blockUntilTimestamp: now + 100) but has NO fade @@ -78,6 +88,7 @@ const FADES_ROWS: V2FadesRowType[] = [ faded: 0, postTimestamp: now - 100, deadline: now - 80, + orderHash: '0xok5', }, // filler6 - not in FILLER_TIMESTAMPS, so all counted { @@ -85,6 +96,7 @@ const FADES_ROWS: V2FadesRowType[] = [ faded: 1, postTimestamp: now - 100, deadline: now - 80, + orderHash: '0xfade6a', }, // filler7 - lastPostTimestamp: now - 150, deadline now - 80 > now - 150, so counted { @@ -92,6 +104,7 @@ const FADES_ROWS: V2FadesRowType[] = [ faded: 1, postTimestamp: now - 100, deadline: now - 80, + orderHash: '0xfade7a', }, // filler8 - lastPostTimestamp: now - 150, deadline now - 80 > now - 150, so counted { @@ -99,6 +112,7 @@ const FADES_ROWS: V2FadesRowType[] = [ faded: 0, postTimestamp: now - 100, deadline: now - 80, + orderHash: '0xok8', }, ]; @@ -149,6 +163,19 @@ describe('FadeRateCron test', () => { }); }); + describe('getFillersNewFadedOrderHashes', () => { + it('collects the hashes of new faded orders per filler, across filler addresses', () => { + expect(getFillersNewFadedOrderHashes(FADES_ROWS, ADDRESS_TO_FILLER, FILLER_TIMESTAMPS)).toEqual({ + filler1: ['0xfade1a', '0xfade1b', '0xfade1c'], + filler2: ['0xfade2a'], // '0xfade2old' completed before filler2's lastPostTimestamp + filler3: ['0xfade3a'], + filler6: ['0xfade6a'], + filler7: ['0xfade7a'], + // fillers with no new fades (filler4, filler5, filler8) have no entry + }); + }); + }); + describe('calculateNewTimestamps', () => { let newTimestamps: ToUpdateTimestampRow[]; @@ -247,6 +274,94 @@ describe('FadeRateCron test', () => { }); }); + describe('fadedOrderHashes persistence', () => { + it('stores the hashes of the faded orders that caused a new block', () => { + const fadedOrderHashes = getFillersNewFadedOrderHashes(FADES_ROWS, ADDRESS_TO_FILLER, FILLER_TIMESTAMPS); + const result = calculateNewTimestamps(FILLER_TIMESTAMPS, newFades, now, logger, undefined, fadedOrderHashes); + + const filler1 = result.find((t) => t.hash === 'filler1'); + expect(filler1?.fadedOrderHashes).toEqual(['0xfade1a', '0xfade1b', '0xfade1c']); + }); + + it('appends new faded order hashes to the stored ones when extending a block', () => { + const timestamps: FillerTimestamps = new Map([ + [ + 'blocked', + { + lastPostTimestamp: now - 100, + blockUntilTimestamp: now + 500, + consecutiveBlocks: 1, + fadedOrderHashes: ['0xold'], + }, + ], + ]); + + const result = calculateNewTimestamps(timestamps, { blocked: 1 }, now, logger, undefined, { + blocked: ['0xnew'], + }); + expect(result[0].fadedOrderHashes).toEqual(['0xold', '0xnew']); + }); + + it('carries stored hashes forward when blocked with no new fades', () => { + const timestamps: FillerTimestamps = new Map([ + [ + 'blocked', + { + lastPostTimestamp: now - 100, + blockUntilTimestamp: now + 500, + consecutiveBlocks: 1, + fadedOrderHashes: ['0xold'], + }, + ], + ]); + + const result = calculateNewTimestamps(timestamps, { blocked: 0 }, now, logger); + expect(result[0].fadedOrderHashes).toEqual(['0xold']); + }); + + it('clears stored hashes when the block expires with no new fades', () => { + const timestamps: FillerTimestamps = new Map([ + [ + 'unblocked', + { + lastPostTimestamp: now - 100, + blockUntilTimestamp: now - 50, + consecutiveBlocks: 1, + fadedOrderHashes: ['0xold'], + }, + ], + ]); + + const result = calculateNewTimestamps(timestamps, { unblocked: 0 }, now, logger); + expect(result[0].blockUntilTimestamp).toBe(UNBLOCKED_BLOCK_UNTIL_TIMESTAMP); + expect(result[0].fadedOrderHashes).toBeUndefined(); + }); + + it('dedupes and caps stored hashes at MAX_FADED_ORDER_HASHES, keeping the most recent', () => { + const existing = Array.from({ length: MAX_FADED_ORDER_HASHES }, (_, i) => `0x${i}`); + const timestamps: FillerTimestamps = new Map([ + [ + 'blocked', + { + lastPostTimestamp: now - 100, + blockUntilTimestamp: now + 500, + consecutiveBlocks: 1, + fadedOrderHashes: existing, + }, + ], + ]); + + const result = calculateNewTimestamps(timestamps, { blocked: 2 }, now, logger, undefined, { + blocked: ['0x1', '0xnew'], // '0x1' is already stored + }); + const hashes = result[0].fadedOrderHashes!; + expect(hashes.length).toBe(MAX_FADED_ORDER_HASHES); + expect(hashes[hashes.length - 1]).toBe('0xnew'); + expect(hashes).not.toContain('0x0'); // oldest dropped + expect(hashes.filter((h) => h === '0x1').length).toBe(1); // deduped + }); + }); + describe('Alternating fade/clean cycle gaming', () => { it('requires multiple clean cycles to fully reset consecutiveBlocks', () => { // Simulate: Filler built up consecutiveBlocks: 3, now has a clean cycle @@ -394,6 +509,7 @@ describe('FadeRateCron test', () => { faded: 1, postTimestamp: now - 100, // Before lastPostTimestamp deadline: now - 50, // After lastPostTimestamp + orderHash: '0xinflight', }, ]; diff --git a/test/entities/HardQuoteRequest.test.ts b/test/entities/HardQuoteRequest.test.ts index 1bf255be..11b2284a 100644 --- a/test/entities/HardQuoteRequest.test.ts +++ b/test/entities/HardQuoteRequest.test.ts @@ -171,22 +171,6 @@ describe('QuoteRequest', () => { }); }); - it('toQuoteRequest carries the order hash', async () => { - const order = new UnsignedV2DutchOrder( - getOrderInfo({ - swapper: SWAPPER, - }), - CHAIN_ID - ); - const request = makeRequest({ encodedInnerOrder: order.serialize(), innerSig: '0x' }); - const quoteRequest = request.toQuoteRequest(); - expect(quoteRequest.orderHash).toEqual(order.hash()); - expect(quoteRequest.quoteId).toEqual(QUOTE_ID); - // the order hash is internal only — it must not leak into the wire payloads - expect(quoteRequest.toJSON()).not.toHaveProperty('orderHash'); - expect(quoteRequest.toCleanJSON()).not.toHaveProperty('orderHash'); - }); - it('exposes protocol v2 for Dutch_V2 orders', () => { const order = new UnsignedV2DutchOrder( getOrderInfo({ diff --git a/test/fixtures.ts b/test/fixtures.ts index 774df865..24a9eacb 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -7,10 +7,23 @@ export const WEBHOOK_URL_ONEINCH = 'https://1inch.io'; export const WEBHOOK_URL_SEARCHER = 'https://searcher.com'; export const WEBHOOK_URL_FOO = 'https://foo.com'; +export const FADED_ORDER_HASHES = [ + '0x1111111111111111111111111111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222222222222222222222222222', +]; + export const MOCK_V2_CB_PROVIDER = new MockV2CircuitBreakerConfigurationProvider( [WEBHOOK_URL, WEBHOOK_URL_ONEINCH, WEBHOOK_URL_SEARCHER], new Map([ - [WEBHOOK_URL_ONEINCH, { blockUntilTimestamp: now + 100000, lastPostTimestamp: now - 10, consecutiveBlocks: 0 }], + [ + WEBHOOK_URL_ONEINCH, + { + blockUntilTimestamp: now + 100000, + lastPostTimestamp: now - 10, + consecutiveBlocks: 0, + fadedOrderHashes: FADED_ORDER_HASHES, + }, + ], [WEBHOOK_URL_SEARCHER, { blockUntilTimestamp: now - 10, lastPostTimestamp: now - 100, consecutiveBlocks: NaN }], ]) ); diff --git a/test/providers/circuit-breaker/cb-provider.test.ts b/test/providers/circuit-breaker/cb-provider.test.ts index 770dfef1..df1d3869 100644 --- a/test/providers/circuit-breaker/cb-provider.test.ts +++ b/test/providers/circuit-breaker/cb-provider.test.ts @@ -6,7 +6,15 @@ const now = Math.floor(Date.now() / 1000); const FILLER_TIMESTAMPS: FillerTimestamps = new Map([ ['filler1', { lastPostTimestamp: now - 150, blockUntilTimestamp: NaN, consecutiveBlocks: NaN }], ['filler2', { lastPostTimestamp: now - 75, blockUntilTimestamp: now - 50, consecutiveBlocks: 0 }], - ['filler3', { lastPostTimestamp: now - 101, blockUntilTimestamp: now + 1000, consecutiveBlocks: 0 }], + [ + 'filler3', + { + lastPostTimestamp: now - 101, + blockUntilTimestamp: now + 1000, + consecutiveBlocks: 0, + fadedOrderHashes: ['0xfaded1', '0xfaded2'], + }, + ], ['filler4', { lastPostTimestamp: now - 150, blockUntilTimestamp: NaN, consecutiveBlocks: 0 }], ['filler5', { lastPostTimestamp: now - 150, blockUntilTimestamp: now + 100, consecutiveBlocks: 1 }], ]); @@ -69,6 +77,7 @@ describe('V2CircuitBreakerProvider', () => { hash: '0xfiller3', }, blockUntil: now + 1000, + fadedOrderHashes: ['0xfaded1', '0xfaded2'], }, { webhook: { @@ -76,6 +85,7 @@ describe('V2CircuitBreakerProvider', () => { endpoint: 'filler5', hash: '0xfiller5', }, + // no fadedOrderHashes: legacy block entry written before hashes were persisted blockUntil: now + 100, }, ], diff --git a/test/providers/quoters/WebhookQuoter.test.ts b/test/providers/quoters/WebhookQuoter.test.ts index 856bf1c0..d656a239 100644 --- a/test/providers/quoters/WebhookQuoter.test.ts +++ b/test/providers/quoters/WebhookQuoter.test.ts @@ -7,10 +7,12 @@ import { NOTIFICATION_TIMEOUT_MS } from '../../../lib/constants'; import { AnalyticsEventType, QuoteRequest, WebhookResponseType } from '../../../lib/entities'; import { MockWebhookConfigurationProvider, ProtocolVersion } from '../../../lib/providers'; import { FirehoseLogger } from '../../../lib/providers/analytics'; +import { MockV2CircuitBreakerConfigurationProvider } from '../../../lib/providers/circuit-breaker/mock'; import { MockFillerComplianceConfigurationProvider } from '../../../lib/providers/compliance'; import { WebhookQuoter } from '../../../lib/quoters'; import { MockFillerAddressRepository } from '../../../lib/repositories/filler-address-repository'; import { + FADED_ORDER_HASHES, MOCK_V2_CB_PROVIDER, WEBHOOK_URL, WEBHOOK_URL_FOO, @@ -24,7 +26,7 @@ const mockedAxios = axios as jest.Mocked; const QUOTE_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; const REQUEST_ID = 'a83f397c-8ef4-4801-a9b7-6e79155049f6'; -const ORDER_HASH = '0x1111111111111111111111111111111111111111111111111111111111111111'; +const now = Math.floor(Date.now() / 1000); const SWAPPER = '0x0000000000000000000000000000000000000000'; const TOKEN_IN = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'; const TOKEN_OUT = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; @@ -365,13 +367,14 @@ describe('WebhookQuoter tests', () => { }); }); - // soft quote: no signed order exists yet, so the notification falls back to requestId + // exact payload match: the notification names the faded orders that caused + // the block and carries no requestId await webhookQuoter.quote(request); expect(mockedAxios.post).toBeCalledWith( WEBHOOK_URL_ONEINCH, { blockUntilTimestamp: expect.any(Number), - requestId: REQUEST_ID, + orderHashes: FADED_ORDER_HASHES, }, { headers: {}, @@ -380,7 +383,7 @@ describe('WebhookQuoter tests', () => { ); }); - it('includes the triggering order hash and quoteId in block notification when present', async () => { + it('includes the quoteId in block notification when the request carries one', async () => { mockedAxios.post .mockImplementationOnce((_endpoint, _req, _options) => { return Promise.resolve({ @@ -397,15 +400,14 @@ describe('WebhookQuoter tests', () => { }); }); - // hard quote requests carry the signed order's hash and quoteId - const hardQuoteRequest = makeQuoteRequest({ orderHash: ORDER_HASH, quoteId: QUOTE_ID }); + // hard quote requests carry the order's quoteId + const hardQuoteRequest = makeQuoteRequest({ quoteId: QUOTE_ID }); await webhookQuoter.quote(hardQuoteRequest); - // exact payload match: requestId must not be included when the order hash is present expect(mockedAxios.post).toBeCalledWith( WEBHOOK_URL_ONEINCH, { blockUntilTimestamp: expect.any(Number), - orderHash: ORDER_HASH, + orderHashes: FADED_ORDER_HASHES, quoteId: QUOTE_ID, }, { @@ -415,6 +417,39 @@ describe('WebhookQuoter tests', () => { ); }); + it('omits orderHashes for legacy block entries without persisted faded order hashes', async () => { + const legacyCbProvider = new MockV2CircuitBreakerConfigurationProvider( + [WEBHOOK_URL, WEBHOOK_URL_ONEINCH], + new Map([ + // written before fadedOrderHashes existed + [ + WEBHOOK_URL_ONEINCH, + { blockUntilTimestamp: now + 100000, lastPostTimestamp: now - 10, consecutiveBlocks: 0 }, + ], + ]) + ); + const legacyWebhookQuoter = new WebhookQuoter( + logger, + mockFirehoseLogger, + webhookProvider, + legacyCbProvider, + emptyMockComplianceProvider, + repository + ); + + await legacyWebhookQuoter.quote(request); + expect(mockedAxios.post).toBeCalledWith( + WEBHOOK_URL_ONEINCH, + { + blockUntilTimestamp: expect.any(Number), + }, + { + headers: {}, + timeout: NOTIFICATION_TIMEOUT_MS, + } + ); + }); + it('Calls to all endpoints if tokenIn is permissioned', async () => { mockedAxios.post .mockImplementationOnce((_endpoint, _req, _options) => { @@ -598,7 +633,7 @@ describe('WebhookQuoter tests', () => { // blocked expect(mockedAxios.post).toBeCalledWith( WEBHOOK_URL_ONEINCH, - { blockUntilTimestamp: expect.any(Number), requestId: REQUEST_ID }, + { blockUntilTimestamp: expect.any(Number), orderHashes: FADED_ORDER_HASHES }, { headers: {}, timeout: NOTIFICATION_TIMEOUT_MS } ); expect(mockedAxios.post).toBeCalledWith( diff --git a/test/repositories/timestamp-repository.test.ts b/test/repositories/timestamp-repository.test.ts index 8a01886c..08be5691 100644 --- a/test/repositories/timestamp-repository.test.ts +++ b/test/repositories/timestamp-repository.test.ts @@ -36,6 +36,7 @@ describe('Dynamo TimestampRepo tests', () => { lastPostTimestamp: 3, blockUntilTimestamp: 6, consecutiveBlocks: 1, + fadedOrderHashes: ['0xfaded1', '0xfaded2'], }, ]; @@ -46,6 +47,8 @@ describe('Dynamo TimestampRepo tests', () => { expect(row?.lastPostTimestamp).toBe(1); expect(row?.blockUntilTimestamp).toBe(NaN); expect(row?.consecutiveBlocks).toBe(0); + // written without fadedOrderHashes (legacy shape) — reads back as undefined + expect(row?.fadedOrderHashes).toBeUndefined(); row = await repo.getFillerTimestamps('0x2'); expect(row).toBeDefined(); @@ -58,6 +61,7 @@ describe('Dynamo TimestampRepo tests', () => { expect(row?.lastPostTimestamp).toBe(3); expect(row?.blockUntilTimestamp).toBe(6); expect(row?.consecutiveBlocks).toBe(1); + expect(row?.fadedOrderHashes).toEqual(['0xfaded1', '0xfaded2']); }); it('should batch get timestamps', async () => { @@ -82,6 +86,7 @@ describe('Dynamo TimestampRepo tests', () => { lastPostTimestamp: 3, blockUntilTimestamp: 6, consecutiveBlocks: 1, + fadedOrderHashes: ['0xfaded1', '0xfaded2'], }, ]) );