diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 0a71b660830..d26ff5d6367 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Use Lighter's reported executed amount for order filled sizes so canceled orders do not appear fully filled. ([#10308](https://github.com/MetaMask/core/pull/10308)) + ## [17.2.0] ### Changed diff --git a/packages/perps-controller/src/services/LighterClientService.ts b/packages/perps-controller/src/services/LighterClientService.ts index 79095164209..888061db332 100644 --- a/packages/perps-controller/src/services/LighterClientService.ts +++ b/packages/perps-controller/src/services/LighterClientService.ts @@ -238,6 +238,7 @@ const OrderStruct = type({ ownerAccountIndex: NonNegativeIntegerStruct, initialBaseAmount: PositiveDecimalStringStruct, remainingBaseAmount: NonNegativeDecimalStringStruct, + filledBaseAmount: optional(NonNegativeDecimalStringStruct), price: PositiveDecimalStringStruct, isAsk: boolean(), type: string(), diff --git a/packages/perps-controller/src/types/lighter-types.ts b/packages/perps-controller/src/types/lighter-types.ts index 0eb02b7dfdb..d980ed017e5 100644 --- a/packages/perps-controller/src/types/lighter-types.ts +++ b/packages/perps-controller/src/types/lighter-types.ts @@ -783,6 +783,8 @@ export type LighterApiOrder = { ownerAccountIndex: number; initialBaseAmount: string; remainingBaseAmount: string; + /** Executed base amount; zero remaining does not imply a fill on cancellation. */ + filledBaseAmount?: string; price: string; isAsk: boolean; type: string; diff --git a/packages/perps-controller/src/utils/lighterAdapter.ts b/packages/perps-controller/src/utils/lighterAdapter.ts index 2d91c08044f..3f69dff6bce 100644 --- a/packages/perps-controller/src/utils/lighterAdapter.ts +++ b/packages/perps-controller/src/utils/lighterAdapter.ts @@ -660,7 +660,11 @@ export function adaptOrderFromLighter( ): Order { const original = parseFloat(order.initialBaseAmount); const remaining = parseFloat(order.remainingBaseAmount); - const filled = Math.max(original - remaining, 0); + // Canceled orders have zero remaining size even when nothing filled. + const filled = + order.filledBaseAmount === undefined + ? Math.max(original - remaining, 0) + : parseFloat(order.filledBaseAmount); const isTrigger = !['market', 'limit'].includes(order.type); const triggerPrice = diff --git a/packages/perps-controller/tests/e2e/lighter-order-history.e2e.ts b/packages/perps-controller/tests/e2e/lighter-order-history.e2e.ts new file mode 100644 index 00000000000..f5119711c74 --- /dev/null +++ b/packages/perps-controller/tests/e2e/lighter-order-history.e2e.ts @@ -0,0 +1,262 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; +/** + * Read one existing Lighter testnet cancellation through PerpsController.getOrders. + * Run with tsx, not Jest. The injected signer module must export signerBridge and + * restore an existing client-owned key. HTTP writes and transaction signing are + * blocked; no orders or venue keys are registered by this driver. + */ +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; + +import { PerpsController } from '../../src/PerpsController.js'; +import type { + PerpsControllerMessenger, + PerpsControllerActions, + PerpsControllerEvents, +} from '../../src/PerpsController.js'; +import type { PerpsPlatformDependencies } from '../../src/types/index.js'; +import type { LighterSignerBridge } from '../../src/types/lighter-types.js'; +import type { + PerpsControllerAllowedActions, + PerpsControllerAllowedEvents, +} from '../../src/types/messenger.js'; + +const { values } = parseArgs({ + options: Object.fromEntries( + [ + 'address', + 'account-index', + 'api-key-index', + 'market', + 'order-id', + 'client-order-id', + 'filled-size', + 'signer-module', + 'out', + ].map((name) => [name, { type: 'string' as const }]), + ), +}); +for (const name of [ + 'address', + 'account-index', + 'api-key-index', + 'market', + 'order-id', + 'client-order-id', + 'filled-size', + 'signer-module', + 'out', +]) { + assert.equal(typeof values[name], 'string', `Missing --${name}`); +} +const address = String(values.address).toLowerCase(); +const accountIndex = Number(values['account-index']); +const apiKeyIndex = Number(values['api-key-index']); +assert.match(address, /^0x[0-9a-f]{40}$/u); +assert(Number.isSafeInteger(accountIndex) && accountIndex >= 0); +assert( + Number.isSafeInteger(apiKeyIndex) && apiKeyIndex >= 0 && apiKeyIndex < 255, +); +assert.match(String(values['filled-size']), /^\d+(?:\.\d+)?$/u); + +const { signerBridge: injectedSigner } = (await import( + pathToFileURL(resolve(String(values['signer-module']))).href +)) as { signerBridge: LighterSignerBridge }; +const signerBridge: LighterSignerBridge = { + async createClient(params) { + assert.equal(params.chainId, 300); + assert.equal(params.accountIndex, accountIndex); + assert.equal(params.apiKeyIndex, apiKeyIndex); + return await injectedSigner.createClient(params); + }, + async execute(call) { + assert.equal( + call.function, + '_createAuthToken', + 'Transaction signing is forbidden', + ); + return await injectedSigner.execute(call); + }, +}; + +// Only platform integrations are inert. Controller, provider, validation, +// normalization, signer and HTTP reads use their real implementations. +const providerErrors: string[] = []; +const noop = (): undefined => undefined; +const infrastructure: PerpsPlatformDependencies = { + logger: { error: noop }, + debugLogger: { + log: (_message, meta) => { + if (meta && typeof meta === 'object' && 'error' in meta) { + providerErrors.push(String(meta.error)); + } + }, + }, + metrics: { isEnabled: () => false, trackPerpsEvent: noop }, + performance: { now: () => performance.now() }, + tracer: { + trace: noop, + endTrace: noop, + setMeasurement: noop, + addBreadcrumb: noop, + }, + streamManager: { + pauseChannel: noop, + resumeChannel: noop, + clearAllChannels: noop, + }, + featureFlags: { validateVersionGated: () => undefined }, + marketDataFormatters: { + formatVolume: String, + formatPerpsFiat: String, + formatPercentage: String, + priceRangesUniversal: [], + }, + cacheInvalidator: { invalidate: noop, invalidateAll: noop }, + diskCache: { + getItem: async () => null, + setItem: async () => undefined, + removeItem: async () => undefined, + }, + rewards: { getPerpsDiscountForAccount: async () => null }, +}; +const root = new Messenger< + MockAnyNamespace, + PerpsControllerActions | PerpsControllerAllowedActions, + PerpsControllerEvents | PerpsControllerAllowedEvents +>({ namespace: MOCK_ANY_NAMESPACE }); +const messenger = new Messenger< + 'PerpsController', + PerpsControllerActions | PerpsControllerAllowedActions, + PerpsControllerEvents | PerpsControllerAllowedEvents, + typeof root +>({ namespace: 'PerpsController', parent: root }); +root.registerActionHandler('AccountsController:getSelectedAccount', () => ({ + id: 'lighter-read-proof', + address, + type: 'eip155:eoa', + metadata: { + name: 'Lighter read proof', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + options: {}, + methods: [], + scopes: ['eip155:0'], +})); +root.delegate({ + actions: ['AccountsController:getSelectedAccount'], + messenger, +}); +const controller = new PerpsController({ + messenger: messenger as PerpsControllerMessenger, + state: { isTestnet: true, activeProvider: 'lighter' }, + clientConfig: { + providerCredentials: { + lighter: { + enabled: true, + accountIndexTestnet: accountIndex, + apiKeyIndex, + signerBridge, + }, + }, + }, + infrastructure, + deferEligibilityCheck: true, +}); + +const { fetch } = globalThis; +const requests: string[] = []; +let wireOrder: Record | undefined; +globalThis.fetch = async (input, init): Promise => { + const request = new globalThis.Request(input, init); + const url = new URL(request.url); + assert.equal(request.method, 'GET', 'Venue writes are forbidden'); + assert.equal(url.origin, 'https://testnet.zklighter.elliot.ai'); + requests.push(url.pathname); + const response = await fetch(request); + if (url.pathname === '/api/v1/accountInactiveOrders') { + assert.equal(url.searchParams.get('account_index'), String(accountIndex)); + const payload = (await response.clone().json()) as { + orders?: Record[]; + }; + const matches = + payload.orders?.filter( + (order) => String(order.order_index) === values['order-id'], + ) ?? []; + assert.equal( + matches.length, + 1, + 'Exact canceled receipt must be present in live history', + ); + [wireOrder] = matches; + } + return response; +}; + +const outputPath = resolve(String(values.out)); +try { + await controller.init(); + assert.equal(controller.state.activeProvider, 'lighter'); + const orders = await controller.getOrders(undefined, { forceRefresh: true }); + assert( + wireOrder, + `Controller must read live Lighter inactive orders: ${JSON.stringify({ requests, providerErrors })}`, + ); + assert.equal(String(wireOrder.client_order_index), values['client-order-id']); + assert.equal(Number(wireOrder.owner_account_index), accountIndex); + assert.equal(wireOrder.status, 'canceled'); + assert.equal(Number(wireOrder.remaining_base_amount), 0); + assert.equal( + Number(wireOrder.filled_base_amount), + Number(values['filled-size']), + ); + const matches = orders.filter( + (order) => order.orderId === values['order-id'], + ); + assert.equal(matches.length, 1); + const [order] = matches; + assert.equal(order.providerId, 'lighter'); + assert.equal(order.symbol, values.market); + assert.equal(order.status, 'canceled'); + const report = { + endpoint: 'PerpsController.getOrders', + network: 'testnet', + provider: 'lighter', + account: address, + accountIndex, + apiKeyIndex, + orderId: order.orderId, + clientOrderId: values['client-order-id'], + symbol: order.symbol, + venue: { + original: wireOrder.initial_base_amount, + remaining: wireOrder.remaining_base_amount, + filled: wireOrder.filled_base_amount, + }, + normalized: { + original: order.originalSize, + remaining: order.remainingSize, + filled: order.filledSize, + status: order.status, + }, + expectedFilledSize: values['filled-size'], + requests, + ok: Number(order.filledSize) === Number(values['filled-size']), + }; + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`); + assert.equal( + report.ok, + true, + `Controller filled size ${order.filledSize} differs from venue ${String(wireOrder.filled_base_amount)}`, + ); + process.stdout.write(`${JSON.stringify(report)}\n`); +} finally { + await controller.disconnect(); + globalThis.fetch = fetch; +} diff --git a/packages/perps-controller/tests/src/services/LighterClientService.test.ts b/packages/perps-controller/tests/src/services/LighterClientService.test.ts index 4ea23fd52b5..4666cac7a07 100644 --- a/packages/perps-controller/tests/src/services/LighterClientService.test.ts +++ b/packages/perps-controller/tests/src/services/LighterClientService.test.ts @@ -290,6 +290,61 @@ describe('LighterClientService', () => { }); }); + describe('order filled amounts', () => { + const order = { + order_index: 12345, + client_order_index: 999, + market_index: 1, + owner_account_index: 28, + initial_base_amount: '0.5', + remaining_base_amount: '0', + price: '90000', + is_ask: false, + type: 'limit', + time_in_force: 'good-till-time', + reduce_only: 0, + status: 'canceled', + order_expiry: 0, + timestamp: 1700000000, + }; + + it.each(['0', '0.2', undefined])( + 'accepts reported filled amount %s', + async (filledBaseAmount) => { + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + orders: [{ ...order, filled_base_amount: filledBaseAmount }], + }), + ); + + const result = await buildService().getInactiveOrders(28, 'auth-token'); + + expect(result.orders).toHaveLength(1); + expect(result.orders[0]).toMatchObject({ + remainingBaseAmount: '0', + filledBaseAmount, + }); + }, + ); + + it.each(['-0.1', 'NaN', '0.1invalid', '', 0, null])( + 'rejects malformed reported filled amount %s', + async (filledBaseAmount) => { + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + orders: [{ ...order, filled_base_amount: filledBaseAmount }], + }), + ); + + await expect( + buildService().getInactiveOrders(28, 'auth-token'), + ).rejects.toThrow('Invalid Lighter venue data'); + }, + ); + }); + describe('getTrades', () => { it('encodes cursor, from, and market filters', async () => { fetchMock.mockResolvedValue( diff --git a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts index a21a7994c5e..be4b4c64039 100644 --- a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts +++ b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts @@ -699,6 +699,35 @@ describe('lighterAdapter', () => { expect(parseFloat(adapted.filledSize)).toBeCloseTo(0.2, 10); }); + it.each(['0', '0.2'])( + 'preserves reported filled size %s when cancellation clears the remaining size', + (filledBaseAmount) => { + const canceledOrder = { + ...order, + status: 'canceled', + remainingBaseAmount: '0', + filledBaseAmount, + }; + + const adapted = adaptOrderFromLighter(canceledOrder, 'BTC'); + + expect(adapted).toMatchObject({ + status: 'canceled', + originalSize: '0.5', + remainingSize: '0', + filledSize: filledBaseAmount, + }); + }, + ); + + it('prefers the reported filled size for open orders', () => { + const openOrder = { ...order, filledBaseAmount: '0.1' }; + + const adapted = adaptOrderFromLighter(openOrder, 'BTC'); + + expect(adapted.filledSize).toBe('0.1'); + }); + it('maps ask orders to sell side', () => { const adapted = adaptOrderFromLighter({ ...order, isAsk: true }, 'BTC'); expect(adapted.side).toBe('sell');