From 7a4bf631fc4031b305878422f33c04992aabf097 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sat, 5 Sep 2026 18:01:43 +0800 Subject: [PATCH 1/2] Keep the terminal TWAP record when a completing fill ties lastUpdated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The venue reports one TWAP lifecycle as several history entries — an activation plus a terminal record — and `lastUpdated` folds in slice fills that every entry sharing a `twapId` receives. A schedule finished by its final fill therefore derives the same `lastUpdated` on both entries, because that fill outranks each entry's own whole-second timestamp. Collapsing entries with `order.lastUpdated >= existing.lastUpdated` admits that tie. The venue returns the terminal record first, so the activation was iterated last and overwrote it, and `resolveTwapOrderStatus` then mapped `activated` to `Active`. A finished schedule stayed listed as live indefinitely, reporting the activation's zero executed size and offering a cancel affordance the venue can no longer act on. Decide terminality first and compare `lastUpdated` strictly, so an equal timestamp keeps the record already collapsed. Both the polled read and the subscription adapter shared the comparison and are both corrected. Co-authored-by: Cursor --- .../src/providers/HyperLiquidProvider.ts | 35 ++++++- ...yperLiquidProvider.strategy-orders.test.ts | 99 +++++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 7f3f3b44cd..f16d7ec756 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -1233,6 +1233,37 @@ const resolveTwapOrderStatus = ( } }; +/** + * Decide whether a newly adapted schedule replaces the one already collapsed + * under the same order id. + * + * The venue reports one lifecycle as several history entries — an activation + * plus a terminal record — and `lastUpdated` folds in slice fills that every + * entry sharing a `twapId` receives. A schedule whose final fill completed it + * therefore derives the same `lastUpdated` on both entries, because that fill + * outranks each entry's own whole-second timestamp. Ordering on `lastUpdated` + * alone admits that tie, letting the activation overwrite the terminal record + * so the schedule reads as live long after it ended. Terminality decides + * first, and an equal `lastUpdated` keeps the entry already collapsed. + * + * @param candidate - Schedule adapted from the current history entry. + * @param existing - Schedule already collapsed under this order id. + * @returns True when the candidate replaces the existing schedule. + */ +const supersedesCollapsedTwapOrder = ( + candidate: TwapOrder, + existing: TwapOrder, +): boolean => { + const candidateIsTerminal = + candidate.status !== PerpsTwapLifecycleStatus.Active; + const existingIsTerminal = + existing.status !== PerpsTwapLifecycleStatus.Active; + if (candidateIsTerminal !== existingIsTerminal) { + return candidateIsTerminal; + } + return candidate.lastUpdated > existing.lastUpdated; +}; + const adaptTwapOrderFill = ( entry: HyperLiquidTwapSliceFillEntry, ): TwapOrderFill => ({ @@ -7672,7 +7703,7 @@ export class HyperLiquidProvider implements PerpsProvider { } const existing = ordersById.get(order.orderId); - if (!existing || order.lastUpdated >= existing.lastUpdated) { + if (!existing || supersedesCollapsedTwapOrder(order, existing)) { ordersById.set(order.orderId, order); } } @@ -13618,7 +13649,7 @@ export class HyperLiquidProvider implements PerpsProvider { continue; } const existing = ordersById.get(order.orderId); - if (!existing || order.lastUpdated >= existing.lastUpdated) { + if (!existing || supersedesCollapsedTwapOrder(order, existing)) { ordersById.set(order.orderId, order); } } diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index 73c012dd45..285dbc42a4 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -2701,6 +2701,105 @@ describe('HyperLiquidProvider - strategy order types', () => { }); }); + describe('when a completing fill ties lastUpdated across history entries', () => { + // The venue reports one lifecycle as an activation plus a terminal + // record, and every entry sharing a twapId receives the same slice + // fills. A schedule finished by its last fill therefore derives an + // identical lastUpdated on both entries, because that fill outranks + // each entry's own whole-second timestamp. + const finishedAtSeconds = 1_700_000_600; + const completingFillTimestamp = finishedAtSeconds * 1_000 + 500; + + const activationEntry = { + time: 1_700_000_000, + twapId: 987, + state: { + coin: 'ETH', + executedNtl: '0', + executedSz: '0', + minutes: 10, + randomize: false, + reduceOnly: false, + side: 'B', + sz: '1', + timestamp: startedAt, + user: userAddress, + }, + status: { status: 'activated' }, + }; + + const terminalEntry = { + time: finishedAtSeconds, + twapId: 987, + state: { + coin: 'ETH', + executedNtl: '3000', + executedSz: '1', + minutes: 10, + randomize: false, + reduceOnly: false, + side: 'B', + sz: '1', + timestamp: startedAt, + user: userAddress, + }, + status: { status: 'finished' }, + }; + + const completingSliceFills = [ + { + twapId: 987, + fill: { + coin: 'ETH', + px: '3000', + sz: '1', + side: 'B', + time: completingFillTimestamp, + startPosition: '0', + dir: 'Open Long', + closedPnl: '0', + hash: '0xabc', + oid: 321, + crossed: true, + fee: '3.00', + tid: 456, + feeToken: 'USDC', + twapId: 987, + }, + }, + ]; + + it.each([ + [ + 'venue order, terminal record first', + [terminalEntry, activationEntry], + ], + ['reversed, activation first', [activationEntry, terminalEntry]], + ])('keeps the terminal record (%s)', async (_label, history) => { + useStrategyClients({ + info: { + twapHistory: jest.fn().mockResolvedValue(history), + userTwapSliceFills: jest + .fn() + .mockResolvedValue(completingSliceFills), + }, + }); + + const orders = await provider.getTwapOrders(); + + expect(orders).toHaveLength(1); + // Reading 'active' here means the activation overwrote the terminal + // record, which leaves a finished schedule listed as live forever. + expect(orders[0]).toMatchObject({ + orderId: '987', + status: 'completed', + executedSize: '1', + remainingSize: '0', + lastUpdated: completingFillTimestamp, + }); + }); + }); + it.each([ ['negative', '-1', '0', '10', 0, 'completed_underfilled'], ['oversized', '11', '10', '0', 10_000, 'completed'], From 912fed83087750109eacb810f272da627700da4b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sat, 5 Sep 2026 18:03:14 +0800 Subject: [PATCH 2/2] Add changelog entry for the TWAP terminal-record dedupe fix Co-authored-by: Cursor --- packages/perps-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 37f40082a3..acdb276350 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Keep the terminal HyperLiquid TWAP record when a completing fill ties `lastUpdated` with the activation, so a finished schedule is no longer reported as live. ([#10122](https://github.com/MetaMask/core/pull/10122)) + - The venue reports one lifecycle as several `twapHistory` entries, an activation plus a terminal record, and slice fills are looked up by `twapId` so every entry of a schedule receives the same fills. `entry.time` is a whole-second value, so when the final fill is what completed the schedule its millisecond timestamp outranks both entries' own timestamps and both derive an identical `lastUpdated`. + - Collapsing entries with `>=` admitted that tie. The venue returns the terminal record first, so the activation was iterated last and overwrote it, `resolveTwapOrderStatus` mapped `activated` to `Active`, and the schedule stayed listed as live carrying the activation's zero executed size while offering a cancel the venue can no longer act on. Schedules that were `terminated` stop seconds after their last fill, never tie, and were unaffected. + - Terminality is now decided before timestamps, and `lastUpdated` is compared strictly so an equal value keeps the record already collapsed. Termination is still only ever taken from venue status and never inferred from elapsed time, so collateral cannot be reclaimed from a live TWAP. Both the polled read and the subscription adapter are corrected. - Handle zero minimum order amounts and margin fractions reported by Lighter for inactive markets by omitting unusable retired rows, while keeping valid delisted metadata and active market values strict. ([#10110](https://github.com/MetaMask/core/pull/10110)) ## [16.1.0]