diff --git a/e2e-testing/.env b/e2e-testing/.env new file mode 100644 index 0000000000..b7671ec50e --- /dev/null +++ b/e2e-testing/.env @@ -0,0 +1 @@ +ETH_RPC_ENDPOINT=https://eth-sepolia.g.alchemy.com/v2/demo diff --git a/e2e-testing/docker-compose-e2e-test.yml b/e2e-testing/docker-compose-e2e-test.yml new file mode 100644 index 0000000000..0357b9dd30 --- /dev/null +++ b/e2e-testing/docker-compose-e2e-test.yml @@ -0,0 +1,229 @@ +version: '3' +services: + kafka: + image: blacktop/kafka:2.6 + ports: + - 9092:9092 + environment: + KAFKA_ADVERTISED_HOST_NAME: kafka + KAFKA_CREATE_TOPICS: + "to-ender:1:1,\ + to-vulcan:1:1,\ + to-websockets-orderbooks:1:1,\ + to-websockets-subaccounts:1:1,\ + to-websockets-trades:1:1,\ + to-websockets-markets:1:1,\ + to-websockets-candles:1:1" + KAFKA_LISTENERS: INTERNAL://:9092,EXTERNAL_SAME_HOST://:29092 + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:9092,EXTERNAL_SAME_HOST://localhost:29092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL_SAME_HOST:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL + DD_AGENT_HOST: datadog-agent + healthcheck: + test: ["CMD-SHELL", "kafka-topics.sh --bootstrap-server 127.0.0.1:9092 --topic to-websockets-candles --describe"] + interval: 5s + timeout: 20s + retries: 50 + postgres: + build: + context: ../indexer + dockerfile: ../indexer/Dockerfile.postgres.local + ports: + - 5435:5432 + environment: + POSTGRES_PASSWORD: dydxserver123 + POSTGRES_USER: dydx_dev + DATADOG_POSTGRES_PASSWORD: dydxserver123 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dydx_dev"] + interval: 5s + timeout: 20s + retries: 10 + redis: + image: redis:5.0.6-alpine + ports: + - 6382:6379 + dydxprotocold0: + image: local:e2etest-dydxprotocol + entrypoint: + - cosmovisor + - run + - start + - --log_level + # Note that only this validator has a log-level of `info`; other validators use `error` by default. + # Change to `debug` for more verbose log-level. + - info + - --home + - /dydxprotocol/chain/.alice + - --p2p.persistent_peers + - "17e5e45691f0d01449c84fd4ae87279578cdd7ec@dydxprotocold0:26656,47539956aaa8e624e0f1d926040e54908ad0eb44@dydxprotocold2:26656,5882428984d83b03d0c907c1f0af343534987052@dydxprotocold3:26656" + - --bridge-daemon-eth-rpc-endpoint + - "${ETH_RPC_ENDPOINT}" + environment: + - DAEMON_HOME=/dydxprotocol/chain/.alice + volumes: + - ../protocol/localnet/dydxprotocol0:/dydxprotocol/chain/.alice/data + ports: + - "26657:26657" + - "9090:9090" + - "1317:1317" + + # This is the Indexer connected node. + # TODO: remove stake and make this a full node. + dydxprotocold1: + image: local:e2etest-dydxprotocol + entrypoint: + - cosmovisor + - run + - start + - --log_level + - error + - --home + - /dydxprotocol/chain/.bob + - --p2p.persistent_peers + - "17e5e45691f0d01449c84fd4ae87279578cdd7ec@dydxprotocold0:26656,b69182310be02559483e42c77b7b104352713166@dydxprotocold1:26656,47539956aaa8e624e0f1d926040e54908ad0eb44@dydxprotocold2:26656,5882428984d83b03d0c907c1f0af343534987052@dydxprotocold3:26656" + - --non-validating-full-node=true + - --bridge-daemon-eth-rpc-endpoint + - "${ETH_RPC_ENDPOINT}" + - --indexer-kafka-conn-str + - "kafka:9092" + environment: + - DAEMON_HOME=/dydxprotocol/chain/.bob + volumes: + - ../protocol/localnet/dydxprotocol1:/dydxprotocol/chain/.bob/data + ports: + - "26658:26657" + depends_on: + kafka: + condition: service_healthy + + dydxprotocold2: + image: local:e2etest-dydxprotocol + entrypoint: + - cosmovisor + - run + - start + - --log_level + - error + - --home + - /dydxprotocol/chain/.carl + - --p2p.persistent_peers + - "17e5e45691f0d01449c84fd4ae87279578cdd7ec@dydxprotocold0:26656,47539956aaa8e624e0f1d926040e54908ad0eb44@dydxprotocold2:26656,5882428984d83b03d0c907c1f0af343534987052@dydxprotocold3:26656" + - --bridge-daemon-eth-rpc-endpoint + - "${ETH_RPC_ENDPOINT}" + environment: + - DAEMON_HOME=/dydxprotocol/chain/.carl + volumes: + - ../protocol/localnet/dydxprotocol2:/dydxprotocol/chain/.carl/data + + dydxprotocold3: + image: local:e2etest-dydxprotocol + entrypoint: + - cosmovisor + - run + - start + - --log_level + - error + - --home + - /dydxprotocol/chain/.dave + - --p2p.persistent_peers + - "17e5e45691f0d01449c84fd4ae87279578cdd7ec@dydxprotocold0:26656,47539956aaa8e624e0f1d926040e54908ad0eb44@dydxprotocold2:26656,5882428984d83b03d0c907c1f0af343534987052@dydxprotocold3:26656" + - --bridge-daemon-eth-rpc-endpoint + - "${ETH_RPC_ENDPOINT}" + environment: + - DAEMON_HOME=/dydxprotocol/chain/.dave + volumes: + - ../protocol/localnet/dydxprotocol3:/dydxprotocol/chain/.dave/data + + postgres-package: + build: + context: ../indexer + dockerfile: ../indexer/Dockerfile.postgres-package.local + links: + - postgres + depends_on: + postgres: + condition: service_healthy + ender: + build: + context: ../indexer + dockerfile: ../indexer/Dockerfile.service.local + args: + service: ender + ports: + - 3001:3001 + links: + - postgres + environment: + - REDIS_URL=redis://redis:6379 + depends_on: + kafka: + condition: service_healthy + postgres-package: + condition: service_completed_successfully + comlink: + build: + context: ../indexer + dockerfile: ../indexer/Dockerfile.service.local + args: + service: comlink + environment: + - PORT=3002 + - REDIS_URL=redis://redis:6379 + - RATE_LIMIT_REDIS_URL=redis://redis:6379 + - RATE_LIMIT_ENABLED=false + - INDEXER_LEVEL_GEOBLOCKING_ENABLED=false + - COMPLIANCE_DATA_CLIENT=PLACEHOLDER + ports: + - 3002:3002 + links: + - postgres + depends_on: + postgres-package: + condition: service_completed_successfully + socks: + build: + context: ../indexer + dockerfile: ../indexer/Dockerfile.service.local + args: + service: socks + ports: + - 3003:3003 + links: + - postgres + environment: + - WS_PORT=3003 + - COMLINK_URL=host.docker.internal:3002 + depends_on: + kafka: + condition: service_healthy + postgres-package: + condition: service_completed_successfully + roundtable: + build: + context: ../indexer + dockerfile: ../indexer/Dockerfile.service.local + args: + service: roundtable + ports: + - 3004:3004 + links: + - postgres + depends_on: + kafka: + condition: service_healthy + postgres-package: + condition: service_completed_successfully + vulcan: + build: + context: ../indexer + dockerfile: ../indexer/Dockerfile.service.local + args: + service: vulcan + environment: + - REDIS_URL=redis://redis:6379 + ports: + - 3005:3005 + depends_on: + kafka: + condition: service_healthy diff --git a/e2e-testing/run-containerized-env.sh b/e2e-testing/run-containerized-env.sh new file mode 100755 index 0000000000..051b5c65f7 --- /dev/null +++ b/e2e-testing/run-containerized-env.sh @@ -0,0 +1,4 @@ +cd ../protocol +make e2etest-build-image +cd ../e2e-testing +docker compose -f docker-compose-e2e-test.yml up diff --git a/indexer/docker-compose-local-deployment.yml b/indexer/docker-compose-local-deployment.yml index 2e5ac3902a..832c27fa85 100644 --- a/indexer/docker-compose-local-deployment.yml +++ b/indexer/docker-compose-local-deployment.yml @@ -100,6 +100,7 @@ services: - DD_PROFILING_ENABLED=true - DD_ENV=localnet_${USER} - DD_AGENT_HOST=datadog-agent + - REDIS_URL=redis://redis:6379 labels: com.datadoghq.ad.logs: '[{"source": "indexer", "service": "ender"}]' depends_on: @@ -113,15 +114,18 @@ services: dockerfile: Dockerfile.service.local args: service: comlink - NPM_TOKEN: ${NPM_TOKEN} environment: # See https://docs.datadoghq.com/profiler/enabling/nodejs/ for DD_ specific environment variables. # Note that DD_SERVICE and DD_VERSION are read by default from package.json - DD_PROFILING_ENABLED=true - DD_ENV=localnet_${USER} - DD_AGENT_HOST=datadog-agent - - TENDERMINT_WS_URL=host.docker.internal:26657 # connects to localhost:26657 on host machine + - REDIS_URL=redis://redis:6379 + - RATE_LIMIT_REDIS_URL=redis://redis:6379 - PORT=3002 + - RATE_LIMIT_ENABLED=false + - INDEXER_LEVEL_GEOBLOCKING_ENABLED=false + - COMPLIANCE_DATA_CLIENT=PLACEHOLDER labels: com.datadoghq.ad.logs: '[{"source": "indexer", "service": "comlink"}]' ports: @@ -191,6 +195,7 @@ services: - DD_PROFILING_ENABLED=true - DD_ENV=localnet_${USER} - DD_AGENT_HOST=datadog-agent + - REDIS_URL=redis://redis:6379 labels: com.datadoghq.ad.logs: '[{"source": "indexer", "service": "vulcan"}]' ports: diff --git a/indexer/packages/postgres/__tests__/db/helpers.test.ts b/indexer/packages/postgres/__tests__/db/helpers.test.ts index ca1a800226..e51da33411 100644 --- a/indexer/packages/postgres/__tests__/db/helpers.test.ts +++ b/indexer/packages/postgres/__tests__/db/helpers.test.ts @@ -45,7 +45,7 @@ describe('helpers', () => { [defaultFundingIndexUpdate.perpetualId]: Big('10050'), }, ), - ).toEqual(Big('20000')); // 10 * (12050-10050) + ).toEqual(Big('-20000')); // 10 * (10050-12050). longs pay shorts when funding index is increasing. }); it('compute unsettled funding for short position', () => { @@ -64,7 +64,7 @@ describe('helpers', () => { [defaultFundingIndexUpdate.perpetualId]: Big('10050'), }, ), - ).toEqual(Big('-20000')); // -10 * (12050-10050) + ).toEqual(Big('20000')); // -10 * (10050-12050). longs pay shorts when funding index is increasing. }); it('compute unsettled funding for decimal position', () => { @@ -82,7 +82,7 @@ describe('helpers', () => { [defaultFundingIndexUpdate.perpetualId]: Big('10050'), }, ), - ).toEqual(Big('2700.1674')); // 1.35 * (12050.124-10050) + ).toEqual(Big('-2700.1674')); // 1.35 * (10050-12050.124). longs pay shorts when funding index is increasing. }); }); diff --git a/indexer/packages/postgres/src/db/helpers.ts b/indexer/packages/postgres/src/db/helpers.ts index 4b13822430..96af31df77 100644 --- a/indexer/packages/postgres/src/db/helpers.ts +++ b/indexer/packages/postgres/src/db/helpers.ts @@ -40,9 +40,14 @@ export function getMaintenanceMarginPpm( * Computes the unsettled funding for a position. * * To compute the net USDC balance for a subaccount, sum the result of this function for all - * open perpetual positions, and subtract the sum from the latest USDC asset position for + * open perpetual positions, and add it to the latest USDC asset position for * this subaccount. * + * When funding index is increasing, shorts get paid & unsettled funding for shorts should + * be positive, vice versa for longs. + * When funding index is decreasing, longs get paid & unsettled funding for longs should + * be positive, vice versa for shorts. + * * @param position * @param latestFundingIndex * @param lastUpdateFundingIndex @@ -53,8 +58,8 @@ export function getUnsettledFunding( lastUpdateFundingIndexMap: FundingIndexMap, ): Big { return Big(position.size).times( - latestFundingIndexMap[position.perpetualId].minus( - lastUpdateFundingIndexMap[position.perpetualId], + lastUpdateFundingIndexMap[position.perpetualId].minus( + latestFundingIndexMap[position.perpetualId], ), ); } diff --git a/indexer/services/comlink/__tests__/controllers/api/v4/addresses-controller.test.ts b/indexer/services/comlink/__tests__/controllers/api/v4/addresses-controller.test.ts index 0ed52f3f3c..9b6724b3c2 100644 --- a/indexer/services/comlink/__tests__/controllers/api/v4/addresses-controller.test.ts +++ b/indexer/services/comlink/__tests__/controllers/api/v4/addresses-controller.test.ts @@ -89,12 +89,12 @@ describe('addresses-controller#V4', () => { testConstants.defaultPerpetualPosition.entryPrice!, ), maxSize: testConstants.defaultPerpetualPosition.maxSize, - // 200000 + 10*(10050-10000)=200500 - netFunding: getFixedRepresentation('200500'), + // 200000 + 10*(10000-10050)=199500 + netFunding: getFixedRepresentation('199500'), // sumClose=0, so realized Pnl is the same as the net funding of the position. // Unsettled funding is funding payments that already "happened" but not reflected // in the subaccount's balance yet, so it's considered a part of realizedPnl. - realizedPnl: getFixedRepresentation('200500'), + realizedPnl: getFixedRepresentation('199500'), // size * (index-entry) = 10*(15000-20000) = -50000 unrealizedPnl: getFixedRepresentation(-50000), status: testConstants.defaultPerpetualPosition.status, @@ -246,12 +246,12 @@ describe('addresses-controller#V4', () => { testConstants.defaultPerpetualPosition.entryPrice!, ), maxSize: testConstants.defaultPerpetualPosition.maxSize, - // 200000 + 10*(10050-10000)=200500 - netFunding: getFixedRepresentation('200500'), + // 200000 + 10*(10000-10050)=199500 + netFunding: getFixedRepresentation('199500'), // sumClose=0, so realized Pnl is the same as the net funding of the position. // Unsettled funding is funding payments that already "happened" but not reflected // in the subaccount's balance yet, so it's considered a part of realizedPnl. - realizedPnl: getFixedRepresentation('200500'), + realizedPnl: getFixedRepresentation('199500'), // size * (index-entry) = 10*(15000-20000) = -50000 unrealizedPnl: getFixedRepresentation(-50000), status: testConstants.defaultPerpetualPosition.status, diff --git a/indexer/services/comlink/__tests__/controllers/api/v4/perpetual-positions-controller.test.ts b/indexer/services/comlink/__tests__/controllers/api/v4/perpetual-positions-controller.test.ts index cf44883a98..22dc791af3 100644 --- a/indexer/services/comlink/__tests__/controllers/api/v4/perpetual-positions-controller.test.ts +++ b/indexer/services/comlink/__tests__/controllers/api/v4/perpetual-positions-controller.test.ts @@ -75,12 +75,12 @@ describe('perpetual-positions-controller#V4', () => { // For the calculation of the net funding (long position): // settled funding on position = 200_000, size = 10, latest funding index = 10050 // last updated funding index = 10000 - // total funding = 200_000 + (10 * (10050 - 10000)) = 200_500 - netFunding: getFixedRepresentation('200500'), + // total funding = 200_000 + (10 * (10000 - 10050)) = 199_500 + netFunding: getFixedRepresentation('199500'), // sumClose=0, so realized Pnl is the same as the net funding of the position. // Unsettled funding is funding payments that already "happened" but not reflected // in the subaccount's balance yet, so it's considered a part of realizedPnl. - realizedPnl: getFixedRepresentation('200500'), + realizedPnl: getFixedRepresentation('199500'), // For the calculation of the unrealized pnl (long position): // index price = 15_000, entry price = 20_000, size = 10 // unrealizedPnl = size * (index price - entry price) @@ -126,12 +126,12 @@ describe('perpetual-positions-controller#V4', () => { // For the calculation of the net funding (short position): // settled funding on position = 200_000, size = -10, latest funding index = 10050 // last updated funding index = 10000 - // total funding = 200_000 + (-10 * (10050 - 10000)) = 199_500 - netFunding: getFixedRepresentation('199500'), + // total funding = 200_000 + (-10 * (10000 - 10050)) = 200_500 + netFunding: getFixedRepresentation('200500'), // sumClose=0, so realized Pnl is the same as the net funding of the position. // Unsettled funding is funding payments that already "happened" but not reflected // in the subaccount's balance yet, so it's considered a part of realizedPnl. - realizedPnl: getFixedRepresentation('199500'), + realizedPnl: getFixedRepresentation('200500'), // For the calculation of the unrealized pnl (short position): // index price = 15_000, entry price = 20_000, size = -10 // unrealizedPnl = size * (index price - entry price) diff --git a/indexer/services/comlink/__tests__/lib/helpers.test.ts b/indexer/services/comlink/__tests__/lib/helpers.test.ts index 3a9b867ec0..8d3badf90d 100644 --- a/indexer/services/comlink/__tests__/lib/helpers.test.ts +++ b/indexer/services/comlink/__tests__/lib/helpers.test.ts @@ -405,8 +405,8 @@ describe('helpers', () => { ); expect(unsettledFunding).toEqual( - Big(perpetualPosition.size).times('100').plus( - Big(perpetualPosition2.size).times('1000'), + Big(perpetualPosition.size).times('-100').plus( + Big(perpetualPosition2.size).times('-1000'), ), ); }); @@ -414,8 +414,8 @@ describe('helpers', () => { describe('adjustUSDCAssetPosition', () => { it.each([ - ['long', PositionSide.LONG, '700', '700'], - ['short', PositionSide.SHORT, '1300', '-1300'], + ['long', PositionSide.LONG, '1300', '1300'], + ['short', PositionSide.SHORT, '700', '-700'], ])('adjusts USDC position size in returned map, size: [%s]', ( _name: string, side: PositionSide, @@ -476,8 +476,8 @@ describe('helpers', () => { }); it.each([ - ['long', 'short', PositionSide.LONG, PositionSide.SHORT, '300', '500', '200', '-200'], - ['short', 'long', PositionSide.SHORT, PositionSide.LONG, '300', '-500', '200', '200'], + ['long', 'short', PositionSide.LONG, PositionSide.LONG, '300', '500', '800', '800'], + ['short', 'long', PositionSide.SHORT, PositionSide.SHORT, '300', '-500', '800', '-800'], ])('flips USDC position side, original side [%s], flipped side [%s]', ( _name: string, _secondName: string, @@ -541,13 +541,12 @@ describe('helpers', () => { }); it.each([ - ['long', PositionSide.LONG, '300', '300'], - ['short', PositionSide.SHORT, '300', '-300'], + ['long', '300', PositionSide.LONG], + ['short', '-300', PositionSide.SHORT], ])('adjusts USDC position when USDC position doesn\'t exist, side [%s]', ( _name: string, + funding: string, expectedSide: PositionSide, - expectedPositionSize: string, - expectedAdjustedPositionSize: string, ) => { const assetPositions: AssetPositionsMap = { BTC: { @@ -564,7 +563,7 @@ describe('helpers', () => { }: { assetPositionsMap: AssetPositionsMap, adjustedUSDCAssetPositionSize: string - } = adjustUSDCAssetPosition(assetPositions, Big(-expectedAdjustedPositionSize)); + } = adjustUSDCAssetPosition(assetPositions, Big(funding)); // Original asset positions object should be unchanged expect(assetPositions).toEqual({ @@ -579,7 +578,7 @@ describe('helpers', () => { [USDC_SYMBOL]: { ...ZERO_USDC_POSITION, side: expectedSide, - size: expectedPositionSize, + size: Big(funding).abs().toString(), }, BTC: { symbol: 'BTC', @@ -588,12 +587,12 @@ describe('helpers', () => { size: '1', }, }); - expect(adjustedUSDCAssetPositionSize).toEqual(expectedAdjustedPositionSize); + expect(adjustedUSDCAssetPositionSize).toEqual(funding); }); it.each([ - ['long', PositionSide.LONG, '300', '300'], - ['short', PositionSide.SHORT, '300', '-300'], + ['long', PositionSide.LONG, '300', '-300'], + ['short', PositionSide.SHORT, '300', '300'], ])('removes USDC position when resulting USDC position size is 0, side [%s]', ( _name: string, side: PositionSide, diff --git a/indexer/services/comlink/src/lib/helpers.ts b/indexer/services/comlink/src/lib/helpers.ts index e9feb31700..3be9ade854 100644 --- a/indexer/services/comlink/src/lib/helpers.ts +++ b/indexer/services/comlink/src/lib/helpers.ts @@ -456,7 +456,7 @@ export function adjustUSDCAssetPosition( } else { signedUsdcPositionSize = ZERO; } - const adjustedSize: Big = signedUsdcPositionSize.minus(unsettledFunding); + const adjustedSize: Big = signedUsdcPositionSize.plus(unsettledFunding); // Update the USDC position in the map if the adjusted size is non-zero if (!adjustedSize.eq(ZERO)) { _.set( diff --git a/indexer/services/roundtable/src/helpers/pnl-ticks-helper.ts b/indexer/services/roundtable/src/helpers/pnl-ticks-helper.ts index 221bd22550..17315647f5 100644 --- a/indexer/services/roundtable/src/helpers/pnl-ticks-helper.ts +++ b/indexer/services/roundtable/src/helpers/pnl-ticks-helper.ts @@ -361,7 +361,7 @@ export function calculateEquity( ZERO, ); - return signedPositionNotional.plus(usdcPositionSize).minus(totalUnsettledFundingPayment); + return signedPositionNotional.plus(usdcPositionSize).plus(totalUnsettledFundingPayment); } /** diff --git a/protocol/Makefile b/protocol/Makefile index 0ceef16a91..c74912db58 100644 --- a/protocol/Makefile +++ b/protocol/Makefile @@ -373,9 +373,15 @@ localnet-compose-upd: @docker build . -t local:dydxprotocol -f testing/testnet-local/Dockerfile --no-cache @docker-compose -f docker-compose.yml up --force-recreate -d $(ARGS) +build-e2etest-image: + @echo "Build e2e test image at commit ${GIT_COMMIT_HASH}" + @docker build . -t local:e2etest-dydxprotocol -f testing/e2etest-local/Dockerfile --no-cache + localnet-start: localnet-init localnet-compose-up localnet-startd: localnet-init localnet-compose-upd +e2etest-build-image: localnet-init build-e2etest-image + # Continue the localnet with the same chain state. localnet-continue: @docker-compose -f docker-compose.yml up $(ARGS) diff --git a/protocol/lib/metrics/constants.go b/protocol/lib/metrics/constants.go index c988e626aa..2b7c511198 100644 --- a/protocol/lib/metrics/constants.go +++ b/protocol/lib/metrics/constants.go @@ -90,7 +90,9 @@ const ( // CLOB. AddPerpetualFillAmount = "add_perpetual_fill_amount" BaseQuantums = "base_quantums" + BestAsk = "best_ask" BestAskClobPair = "best_ask_clob_pair" + BestBid = "best_bid" BestBidClobPair = "best_bid_clob_pair" Buy = "buy" CancelOrder = "cancel_order" @@ -377,10 +379,12 @@ const ( TotalNumIndexerTxnEvents = "total_num_txn_events" // Mev. + MevFallbackToOracle = "mev_fallback_to_oracle" Mev = "mev" MevSentDatapoints = "mev_num_sent_datapoints" MidPrice = "mid_price" MissingMidPrice = "missing_mid_price" + OraclePrice = "oracle_price" ProposerNumFills = "proposer_num_fills" ProposerNumMatchedTakerOrders = "proposer_num_matched_taker_orders" ProposerVolumeQuoteQuantums = "proposer_volume_quote_quantums" diff --git a/protocol/mocks/MemClob.go b/protocol/mocks/MemClob.go index a57c83df6a..6bb77b08cd 100644 --- a/protocol/mocks/MemClob.go +++ b/protocol/mocks/MemClob.go @@ -107,7 +107,7 @@ func (_m *MemClob) GetCancelOrder(ctx types.Context, orderId clobtypes.OrderId) } // GetMidPrice provides a mock function with given fields: ctx, clobPairId -func (_m *MemClob) GetMidPrice(ctx types.Context, clobPairId clobtypes.ClobPairId) (clobtypes.Subticks, bool) { +func (_m *MemClob) GetMidPrice(ctx types.Context, clobPairId clobtypes.ClobPairId) (clobtypes.Subticks, clobtypes.Order, clobtypes.Order, bool) { ret := _m.Called(ctx, clobPairId) var r0 clobtypes.Subticks @@ -117,14 +117,28 @@ func (_m *MemClob) GetMidPrice(ctx types.Context, clobPairId clobtypes.ClobPairI r0 = ret.Get(0).(clobtypes.Subticks) } - var r1 bool - if rf, ok := ret.Get(1).(func(types.Context, clobtypes.ClobPairId) bool); ok { + var r1 clobtypes.Order + if rf, ok := ret.Get(1).(func(types.Context, clobtypes.ClobPairId) clobtypes.Order); ok { r1 = rf(ctx, clobPairId) } else { - r1 = ret.Get(1).(bool) + r1 = ret.Get(1).(clobtypes.Order) } - return r0, r1 + var r2 clobtypes.Order + if rf, ok := ret.Get(2).(func(types.Context, clobtypes.ClobPairId) clobtypes.Order); ok { + r2 = rf(ctx, clobPairId) + } else { + r2 = ret.Get(2).(clobtypes.Order) + } + + var r3 bool + if rf, ok := ret.Get(3).(func(types.Context, clobtypes.ClobPairId) bool); ok { + r3 = rf(ctx, clobPairId) + } else { + r3 = ret.Get(3).(bool) + } + + return r0, r1, r2, r3 } // GetOperationsRaw provides a mock function with given fields: ctx diff --git a/protocol/testing/e2etest-local/Dockerfile b/protocol/testing/e2etest-local/Dockerfile new file mode 100644 index 0000000000..345cf49f66 --- /dev/null +++ b/protocol/testing/e2etest-local/Dockerfile @@ -0,0 +1,14 @@ +FROM dydxprotocol-base + +COPY ./testing/e2etest-local/local.sh /dydxprotocol/local.sh +COPY ./testing/genesis.sh /dydxprotocol/genesis.sh +COPY ./testing/start.sh /dydxprotocol/start.sh +COPY ./daemons/pricefeed/client/constants/testdata /dydxprotocol/exchange_config +COPY ./testing/delaymsg_config /dydxprotocol/delaymsg_config + +RUN go install cosmossdk.io/tools/cosmovisor/cmd/cosmovisor@v1.4.0 + +RUN /dydxprotocol/local.sh + +ENV DAEMON_NAME=dydxprotocold +ENTRYPOINT ["cosmovisor", "run"] diff --git a/protocol/testing/e2etest-local/local.sh b/protocol/testing/e2etest-local/local.sh new file mode 100755 index 0000000000..0e1f28e4c2 --- /dev/null +++ b/protocol/testing/e2etest-local/local.sh @@ -0,0 +1,173 @@ +#!/bin/bash +set -eo pipefail + +# This file initializes muliple validators for local and CI testing purposes. +# This file should be run as part of `docker-compose-e2e-test.yml`. + +source "./genesis.sh" + +CHAIN_ID="localdydxprotocol" + +# Define mnemonics for all validators. +MNEMONICS=( + # alice + # Consensus Address: dydxvalcons1zf9csp5ygq95cqyxh48w3qkuckmpealrw2ug4d + "merge panther lobster crazy road hollow amused security before critic about cliff exhibit cause coyote talent happy where lion river tobacco option coconut small" + + # bob + # Consensus Address: dydxvalcons1s7wykslt83kayxuaktep9fw8qxe5n73ucftkh4 + "color habit donor nurse dinosaur stable wonder process post perfect raven gold census inside worth inquiry mammal panic olive toss shadow strong name drum" + + # carl + # Consensus Address: dydxvalcons1vy0nrh7l4rtezrsakaadz4mngwlpdmhy64h0ls + "school artefact ghost shop exchange slender letter debris dose window alarm hurt whale tiger find found island what engine ketchup globe obtain glory manage" + + # dave + # Consensus Address: dydxvalcons1stjspktkshgcsv8sneqk2vs2ws0nw2wr272vtt + "switch boring kiss cash lizard coconut romance hurry sniff bus accident zone chest height merit elevator furnace eagle fetch quit toward steak mystery nest" +) + +# Define node keys for all validators. +NODE_KEYS=( + # Node ID: 17e5e45691f0d01449c84fd4ae87279578cdd7ec + "8EGQBxfGMcRfH0C45UTedEG5Xi3XAcukuInLUqFPpskjp1Ny0c5XvwlKevAwtVvkwoeYYQSe0geQG/cF3GAcUA==" + + # Node ID: b69182310be02559483e42c77b7b104352713166 + "3OZf5HenMmeTncJY40VJrNYKIKcXoILU5bkYTLzTJvewowU2/iV2+8wSlGOs9LoKdl0ODfj8UutpMhLn5cORlw==" + + # Node ID: 47539956aaa8e624e0f1d926040e54908ad0eb44 + "tWV4uEya9Xvmm/kwcPTnEQIV1ZHqiqUTN/jLPHhIBq7+g/5AEXInokWUGM0shK9+BPaTPTNlzv7vgE8smsFg4w==" + + # Node ID: 5882428984d83b03d0c907c1f0af343534987052 + "++C3kWgFAs7rUfwAHB7Ffrv43muPg0wTD2/UtSPFFkhtobooIqc78UiotmrT8onuT1jg8/wFPbSjhnKRThTRZg==" +) + +# Define monikers for each validator. These are made up strings and can be anything. +# This also controls in which directory the validator's home will be located. i.e. `/dydxprotocol/chain/.alice` +MONIKERS=( + "alice" + "bob" + "carl" + "dave" +) + +# Define all test accounts for the chain. +TEST_ACCOUNTS=( + "dydx199tqg4wdlnu4qjlxchpd7seg454937hjrknju4" # alice + "dydx10fx7sy6ywd5senxae9dwytf8jxek3t2gcen2vs" # bob + "dydx1fjg6zp6vv8t9wvy4lps03r5l4g7tkjw9wvmh70" # carl + "dydx1wau5mja7j7zdavtfq9lu7ejef05hm6ffenlcsn" # dave +) + +FAUCET_ACCOUNTS=( + "dydx1nzuttarf5k2j0nug5yzhr6p74t9avehn9hlh8m" # main faucet +) + +# Define dependencies for this script. +# `jq` and `dasel` are used to manipulate json and yaml files respectively. +install_prerequisites() { + apk add dasel jq +} + +# Create all validators for the chain including a full-node. +# Initialize their genesis files and home directories. +create_validators() { + # Create temporary directory for all gentx files. + mkdir /tmp/gentx + + # Iterate over all validators and set up their home directories, as well as generate `gentx` transaction for each. + for i in "${!MONIKERS[@]}"; do + VAL_HOME_DIR="$HOME/chain/.${MONIKERS[$i]}" + VAL_CONFIG_DIR="$VAL_HOME_DIR/config" + + # Initialize the chain and validator files. + dydxprotocold init "${MONIKERS[$i]}" -o --chain-id=$CHAIN_ID --home "$VAL_HOME_DIR" + + # Overwrite the randomly generated `priv_validator_key.json` with a key generated deterministically from the mnemonic. + dydxprotocold tendermint gen-priv-key --home "$VAL_HOME_DIR" --mnemonic "${MNEMONICS[$i]}" + + # Note: `dydxprotocold init` non-deterministically creates `node_id.json` for each validator. + # This is inconvenient for persistent peering during testing in Terraform configuration as the `node_id` + # would change with every build of this container. + # + # For that reason we overwrite the non-deterministically generated one with a deterministic key defined in this file here. + new_file=$(jq ".priv_key.value = \"${NODE_KEYS[$i]}\"" "$VAL_CONFIG_DIR"/node_key.json) + cat <<<"$new_file" >"$VAL_CONFIG_DIR"/node_key.json + + edit_config "$VAL_CONFIG_DIR" + + # Using "*" as a subscript results in a single arg: "dydx1... dydx1... dydx1..." + # Using "@" as a subscript results in separate args: "dydx1..." "dydx1..." "dydx1..." + # Note: `edit_genesis` must be called before `add-genesis-account`. + edit_genesis "$VAL_CONFIG_DIR" "${TEST_ACCOUNTS[*]}" "${FAUCET_ACCOUNTS[*]}" "" "" "" "" + update_genesis_use_test_volatile_market "$VAL_CONFIG_DIR" + update_all_markets_with_fixed_price_exchange "$VAL_CONFIG_DIR" + update_genesis_complete_bridge_delay "$VAL_CONFIG_DIR" "30" + + echo "${MNEMONICS[$i]}" | dydxprotocold keys add "${MONIKERS[$i]}" --recover --keyring-backend=test --home "$VAL_HOME_DIR" + + for acct in "${TEST_ACCOUNTS[@]}"; do + dydxprotocold add-genesis-account "$acct" 100000000000000000$USDC_DENOM,$TESTNET_VALIDATOR_NATIVE_TOKEN_BALANCE$NATIVE_TOKEN --home "$VAL_HOME_DIR" + done + for acct in "${FAUCET_ACCOUNTS[@]}"; do + dydxprotocold add-genesis-account "$acct" 900000000000000000$USDC_DENOM,$TESTNET_VALIDATOR_NATIVE_TOKEN_BALANCE$NATIVE_TOKEN --home "$VAL_HOME_DIR" + done + + dydxprotocold gentx "${MONIKERS[$i]}" $TESTNET_VALIDATOR_SELF_DELEGATE_AMOUNT$NATIVE_TOKEN --moniker="${MONIKERS[$i]}" --keyring-backend=test --chain-id=$CHAIN_ID --home "$VAL_HOME_DIR" + + # Copy the gentx to a shared directory. + cp -a "$VAL_CONFIG_DIR/gentx/." /tmp/gentx + done + + # Copy gentxs to the first validator's home directory to build the genesis json file + FIRST_VAL_HOME_DIR="$HOME/chain/.${MONIKERS[0]}" + FIRST_VAL_CONFIG_DIR="$FIRST_VAL_HOME_DIR/config" + + rm -rf "$FIRST_VAL_CONFIG_DIR/gentx" + mkdir "$FIRST_VAL_CONFIG_DIR/gentx" + cp -r /tmp/gentx "$FIRST_VAL_CONFIG_DIR" + + # Build the final genesis.json file that all validators and the full-nodes will use. + dydxprotocold collect-gentxs --home "$FIRST_VAL_HOME_DIR" + + # Copy this genesis file to each of the other validators + for i in "${!MONIKERS[@]}"; do + if [[ "$i" == 0 ]]; then + # Skip first moniker as it already has the correct genesis file. + continue + fi + + VAL_HOME_DIR="$HOME/chain/.${MONIKERS[$i]}" + VAL_CONFIG_DIR="$VAL_HOME_DIR/config" + rm -rf "$VAL_CONFIG_DIR/genesis.json" + cp "$FIRST_VAL_CONFIG_DIR/genesis.json" "$VAL_CONFIG_DIR/genesis.json" + done +} + +setup_cosmovisor() { + for i in "${!MONIKERS[@]}"; do + VAL_HOME_DIR="$HOME/chain/.${MONIKERS[$i]}" + export DAEMON_NAME=dydxprotocold + export DAEMON_HOME="$HOME/chain/.${MONIKERS[$i]}" + + cosmovisor init /bin/dydxprotocold + done +} + +# TODO(DEC-1894): remove this function once we migrate off of persistent peers. +# Note: DO NOT add more config modifications in this method. Use `cmd/config.go` to configure +# the default config values. +edit_config() { + CONFIG_FOLDER=$1 + + # Disable pex + dasel put -t bool -f "$CONFIG_FOLDER"/config.toml '.p2p.pex' -v 'false' + + # Default `timeout_commit` is 999ms. For local testnet, use a larger value to make + # block time longer for easier troubleshooting. + dasel put -t string -f "$CONFIG_FOLDER"/config.toml '.consensus.timeout_commit' -v '5s' +} + +install_prerequisites +create_validators +setup_cosmovisor diff --git a/protocol/testing/genesis.sh b/protocol/testing/genesis.sh index da3c29f904..6efae9d71f 100755 --- a/protocol/testing/genesis.sh +++ b/protocol/testing/genesis.sh @@ -1572,6 +1572,37 @@ function update_genesis_use_test_volatile_market() { dasel put -t int -f "$GENESIS" '.app_state.clob.clob_pairs.last().quantum_conversion_exponent' -v '-8' } +# Modify the genesis file to only use fixed price exchange. +function update_all_markets_with_fixed_price_exchange() { + GENESIS=$1/genesis.json + + # Read the number of markets + NUM_MARKETS=$(jq -c '.app_state.prices.market_params | length' < "${GENESIS}") + + # Loop through each market and update the parameters + for ((j = 0; j < NUM_MARKETS; j++)); do + # Get the current ticker + TICKER=$(jq -r ".app_state.prices.market_params[$j].pair" < "${GENESIS}") + + # Update the exchange_config_json using the EOF syntax + exchange_config_json=$(cat <<-EOF +{ + "exchanges": [ + { + "exchangeName": "TestFixedPriceExchange", + "ticker": "${TICKER}" + } + ] +} +EOF + ) + dasel put -t string -f "$GENESIS" ".app_state.prices.market_params.[$j].exchange_config_json" -v "$exchange_config_json" + + # Update the min_exchanges + dasel put -t int -f "$GENESIS" ".app_state.prices.market_params.[$j].min_exchanges" -v "1" + done +} + # Modify the genesis file with reduced complete bridge delay (for testing in non-prod envs). update_genesis_complete_bridge_delay() { GENESIS=$1/genesis.json diff --git a/protocol/testutil/constants/orders.go b/protocol/testutil/constants/orders.go index 7b1b1c3f3a..8f796e6221 100644 --- a/protocol/testutil/constants/orders.go +++ b/protocol/testutil/constants/orders.go @@ -624,6 +624,13 @@ var ( Subticks: 49_500_000_000, GoodTilOneof: &clobtypes.Order_GoodTilBlock{GoodTilBlock: 10}, } + Order_Carl_Num0_Id0_Clob0_Buy1BTC_Price49800_GTB10 = clobtypes.Order{ + OrderId: clobtypes.OrderId{SubaccountId: Carl_Num0, ClientId: 0, ClobPairId: 0}, + Side: clobtypes.Order_SIDE_BUY, + Quantums: 100_000_000, + Subticks: 49_800_000_000, + GoodTilOneof: &clobtypes.Order_GoodTilBlock{GoodTilBlock: 10}, + } Order_Carl_Num0_Id0_Clob0_Buy1BTC_Price50000_GTB10 = clobtypes.Order{ OrderId: clobtypes.OrderId{SubaccountId: Carl_Num0, ClientId: 0, ClobPairId: 0}, Side: clobtypes.Order_SIDE_BUY, @@ -659,6 +666,13 @@ var ( Subticks: 49_500_000_000, GoodTilOneof: &clobtypes.Order_GoodTilBlock{GoodTilBlock: 10}, } + Order_Carl_Num0_Id3_Clob0_Buy025BTC_Price49800 = clobtypes.Order{ + OrderId: clobtypes.OrderId{SubaccountId: Carl_Num0, ClientId: 3, ClobPairId: 0}, + Side: clobtypes.Order_SIDE_BUY, + Quantums: 25_000_000, + Subticks: 49_800_000_000, + GoodTilOneof: &clobtypes.Order_GoodTilBlock{GoodTilBlock: 10}, + } Order_Carl_Num0_Id3_Clob0_Buy025BTC_Price50000 = clobtypes.Order{ OrderId: clobtypes.OrderId{SubaccountId: Carl_Num0, ClientId: 3, ClobPairId: 0}, Side: clobtypes.Order_SIDE_BUY, @@ -919,6 +933,13 @@ var ( Subticks: 3_000_000_000, GoodTilOneof: &clobtypes.Order_GoodTilBlock{GoodTilBlock: 10}, } + Order_Dave_Num0_Id4_Clob1_Sell1ETH_Price3020 = clobtypes.Order{ + OrderId: clobtypes.OrderId{SubaccountId: Dave_Num0, ClientId: 4, ClobPairId: 1}, + Side: clobtypes.Order_SIDE_SELL, + Quantums: 1_000_000_000, + Subticks: 3_020_000_000, + GoodTilOneof: &clobtypes.Order_GoodTilBlock{GoodTilBlock: 10}, + } Order_Dave_Num0_Id4_Clob1_Sell1ETH_Price3030 = clobtypes.Order{ OrderId: clobtypes.OrderId{SubaccountId: Dave_Num0, ClientId: 4, ClobPairId: 1}, Side: clobtypes.Order_SIDE_SELL, diff --git a/protocol/x/clob/keeper/grpc_query_mev_node_to_node.go b/protocol/x/clob/keeper/grpc_query_mev_node_to_node.go index 4b47961ae5..a2f48c716f 100644 --- a/protocol/x/clob/keeper/grpc_query_mev_node_to_node.go +++ b/protocol/x/clob/keeper/grpc_query_mev_node_to_node.go @@ -99,19 +99,19 @@ func (k Keeper) InitializeCumulativePnLsFromRequest( blockProposerPnL map[types.ClobPairId]*CumulativePnL, validatorPnL map[types.ClobPairId]*CumulativePnL, ) { - clobPairs := make(map[types.ClobPairId]types.ClobPair, len(req.ValidatorMevMetrics.ClobMidPrices)) - clobMidPrices := make(map[types.ClobPairId]types.Subticks, len(req.ValidatorMevMetrics.ClobMidPrices)) + clobMetadata := make(map[types.ClobPairId]ClobMetadata, len(req.ValidatorMevMetrics.ClobMidPrices)) for _, clobMidPrice := range req.ValidatorMevMetrics.ClobMidPrices { clobPairId := types.ClobPairId(clobMidPrice.ClobPair.Id) - clobPairs[clobPairId] = clobMidPrice.ClobPair - clobMidPrices[clobPairId] = types.Subticks(clobMidPrice.Subticks) + clobMetadata[clobPairId] = ClobMetadata{ + ClobPair: clobMidPrice.ClobPair, + MidPrice: types.Subticks(clobMidPrice.Subticks), + } } blockProposerPnL, validatorPnL = k.InitializeCumulativePnLs( ctx, k.perpetualsKeeper, - clobMidPrices, - clobPairs, + clobMetadata, ) return blockProposerPnL, validatorPnL diff --git a/protocol/x/clob/keeper/mev.go b/protocol/x/clob/keeper/mev.go index d3ef969ce4..2787556d54 100644 --- a/protocol/x/clob/keeper/mev.go +++ b/protocol/x/clob/keeper/mev.go @@ -16,12 +16,22 @@ import ( satypes "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types" ) +var MAX_SPREAD_BEFORE_FALLING_BACK_TO_ORACLE = new(big.Rat).SetFrac64(1, 100) + type MevTelemetryConfig struct { Enabled bool Hosts []string Identifier string } +type ClobMetadata struct { + ClobPair types.ClobPair + MidPrice types.Subticks + OraclePrice types.Subticks + BestBid types.Order + BestAsk types.Order +} + // CumulativePnL keeps track of the cumulative PnL for each subaccount per market. type CumulativePnL struct { // PnL calculations. @@ -34,8 +44,7 @@ type CumulativePnL struct { // Cached fields used in the calculation of PnL. // These should not be modified after initialization. - ClobPair types.ClobPair - MidPriceSubticks types.Subticks + Metadata ClobMetadata PerpetualFundingIndex *big.Int } @@ -77,21 +86,19 @@ func (k Keeper) RecordMevMetrics( } }() - clobMidPrices, clobPairs := k.GetClobMetadata(ctx) + clobMetadata := k.GetClobMetadata(ctx) // Initialize cumulative PnL for block proposer and validator. blockProposerPnL, validatorPnL := k.InitializeCumulativePnLs( ctx, perpetualKeeper, - clobMidPrices, - clobPairs, + clobMetadata, ) // Calculate the block proposer's PnL from regular and liquidation matches. blockProposerMevMatches, err := k.GetMEVDataFromOperations( ctx, msgProposedOperations.GetOperationsQueue(), - clobPairs, ) if err != nil { k.Logger(ctx).Error( @@ -130,7 +137,6 @@ func (k Keeper) RecordMevMetrics( validatorMevMatches, err := k.GetMEVDataFromOperations( ctx, k.GetOperations(ctx).GetOperationsQueue(), - clobPairs, ) if err != nil { k.Logger(ctx).Error( @@ -284,7 +290,13 @@ func (k Keeper) RecordMevMetrics( metrics.ClobPairId, clobPairId.ToUint32(), metrics.MidPrice, - validatorPnL[clobPairId].MidPriceSubticks.ToUint64(), + validatorPnL[clobPairId].Metadata.MidPrice.ToUint64(), + metrics.OraclePrice, + validatorPnL[clobPairId].Metadata.OraclePrice.ToUint64(), + metrics.BestBid, + fmt.Sprintf("%+v", validatorPnL[clobPairId].Metadata.BestBid), + metrics.BestAsk, + fmt.Sprintf("%+v", validatorPnL[clobPairId].Metadata.BestAsk), // Validator stats. metrics.ValidatorNumFills, validatorPnL[clobPairId].NumFills, @@ -321,13 +333,13 @@ func (k Keeper) RecordMevMetrics( } if len(k.mevTelemetryConfig.Hosts) != 0 { - mevClobMidPrices := make([]types.ClobMidPrice, 0, len(clobPairs)) - for _, clobPair := range clobPairs { + mevClobMidPrices := make([]types.ClobMidPrice, 0, len(clobMetadata)) + for _, metadata := range clobMetadata { mevClobMidPrices = append( mevClobMidPrices, types.ClobMidPrice{ - ClobPair: clobPair, - Subticks: clobMidPrices[types.ClobPairId(clobPair.Id)].ToUint64(), + ClobPair: metadata.ClobPair, + Subticks: metadata.MidPrice.ToUint64(), }, ) } @@ -354,45 +366,58 @@ func (k Keeper) RecordMevMetrics( } // GetClobMetadata fetches the mid prices for all CLOB pairs and the CLOB pairs themselves. -// This function falls back to use the oracle price if any of the mid prices are missing. +// This function falls back to use the oracle price if any of the mid prices are missing +// or if the spread is greater-than-or-equal-to the max spread. func (k Keeper) GetClobMetadata( ctx sdk.Context, ) ( - clobMidPrices map[types.ClobPairId]types.Subticks, - clobPairs map[types.ClobPairId]types.ClobPair, + clobMetadata map[types.ClobPairId]ClobMetadata, ) { - clobMidPrices = make(map[types.ClobPairId]types.Subticks) - clobPairs = make(map[types.ClobPairId]types.ClobPair) + clobMetadata = make(map[types.ClobPairId]ClobMetadata) for _, clobPair := range k.GetAllClobPairs(ctx) { clobPairId := clobPair.GetClobPairId() - var midPriceSubticks types.Subticks - - // Get the mid price if it exists, otherwise get the oracle price. - if midPrice, exist := k.MemClob.GetMidPrice(ctx, clobPairId); exist { - midPriceSubticks = midPrice - } else { - oraclePriceSubticksRat := k.GetOraclePriceSubticksRat(ctx, clobPair) - // Consistently round down here. - oraclePriceSubticksInt := lib.BigRatRound(oraclePriceSubticksRat, false) - if !oraclePriceSubticksInt.IsUint64() { - panic( - fmt.Sprintf( - "GetAllMidPrices: invalid oracle price %+v for clob pair %+v", - oraclePriceSubticksInt, - clobPair, - ), - ) - } - midPriceSubticks = types.Subticks(oraclePriceSubticksInt.Uint64()) + + midPriceSubticks, bestBid, bestAsk, exist := k.MemClob.GetMidPrice(ctx, clobPairId) + oraclePriceSubticksRat := k.GetOraclePriceSubticksRat(ctx, clobPair) + // Consistently round down here. + oraclePriceSubticksInt := lib.BigRatRound(oraclePriceSubticksRat, false) + if !oraclePriceSubticksInt.IsUint64() { + panic( + fmt.Sprintf( + "GetAllMidPrices: invalid oracle price %+v for clob pair %+v", + oraclePriceSubticksInt, + clobPair, + ), + ) + } + oraclePriceSubticks := types.Subticks(oraclePriceSubticksInt.Uint64()) + + // Use the oracle price instead of the mid price if the mid price doesn't exist or + // the spread is greater-than-or-equal-to the max spread. + if !exist || new(big.Rat).SetFrac( + new(big.Int).SetUint64(uint64(bestAsk.Subticks-bestBid.Subticks)), + new(big.Int).SetUint64(uint64(bestBid.Subticks)), // Note that bestBid cannot be 0 if exist is true. + ).Cmp(MAX_SPREAD_BEFORE_FALLING_BACK_TO_ORACLE) >= 0 { + metrics.IncrCounterWithLabels( + metrics.MevFallbackToOracle, + 1, + metrics.GetLabelForIntValue(metrics.ClobPairId, int(clobPairId.ToUint32())), + ) + midPriceSubticks = oraclePriceSubticks } - // Set the CLOB mid price and CLOB pair. - clobMidPrices[clobPairId] = midPriceSubticks - clobPairs[types.ClobPairId(clobPairId)] = clobPair + // Set the CLOB metadata. + clobMetadata[clobPairId] = ClobMetadata{ + ClobPair: clobPair, + MidPrice: midPriceSubticks, + OraclePrice: oraclePriceSubticks, + BestBid: bestBid, + BestAsk: bestAsk, + } } - return clobMidPrices, clobPairs + return clobMetadata } // InitializeCumulativePnLs initializes the cumulative PnLs for the block proposer and the @@ -400,8 +425,7 @@ func (k Keeper) GetClobMetadata( func (k Keeper) InitializeCumulativePnLs( ctx sdk.Context, perpetualKeeper process.ProcessPerpetualKeeper, - clobMidPrices map[types.ClobPairId]types.Subticks, - clobPairs map[types.ClobPairId]types.ClobPair, + clobMetadata map[types.ClobPairId]ClobMetadata, ) ( blockProposerPnL map[types.ClobPairId]*CumulativePnL, validatorPnL map[types.ClobPairId]*CumulativePnL, @@ -409,30 +433,8 @@ func (k Keeper) InitializeCumulativePnLs( blockProposerPnL = make(map[types.ClobPairId]*CumulativePnL) validatorPnL = make(map[types.ClobPairId]*CumulativePnL) - if len(clobMidPrices) != len(clobPairs) { - panic( - fmt.Sprintf( - "InitializeCumulativePnLs: clob mid prices %+v and clob pairs %+v have different lengths", - clobMidPrices, - clobPairs, - ), - ) - } - - for clobPairId, clobPair := range clobPairs { - var midPriceSubticks types.Subticks - - // Panic if the mid price does not exist - midPriceSubticks, exists := clobMidPrices[clobPairId] - if !exists { - panic( - fmt.Sprintf( - "InitializeCumulativePnLs: mid price does not exist for clob pair %+v", - clobPair, - ), - ) - } - + for clobPairId, metadata := range clobMetadata { + clobPair := metadata.ClobPair // Get a mapping from perpetual Id to current perpetual funding index. perpetual, err := perpetualKeeper.GetPerpetual(ctx, clobPair.MustGetPerpetualId()) if err != nil { @@ -448,8 +450,7 @@ func (k Keeper) InitializeCumulativePnLs( SubaccountPositionSizeDelta: make(map[satypes.SubaccountId]*big.Int), NumFills: 0, VolumeQuoteQuantums: big.NewInt(0), - ClobPair: clobPair, - MidPriceSubticks: midPriceSubticks, + Metadata: metadata, PerpetualFundingIndex: perpetual.FundingIndex.BigInt(), } } @@ -463,7 +464,6 @@ func (k Keeper) InitializeCumulativePnLs( func (k Keeper) GetMEVDataFromOperations( ctx sdk.Context, operations []types.OperationRaw, - clobPairs map[types.ClobPairId]types.ClobPair, ) ( validatorMevMatches *types.ValidatorMevMatches, err error, @@ -773,7 +773,7 @@ func (k Keeper) AddSettlementForPositionDelta( clobPairToPnLs map[types.ClobPairId]*CumulativePnL, ) (err error) { for _, cumulativePnL := range clobPairToPnLs { - perpetualId := cumulativePnL.ClobPair.MustGetPerpetualId() + perpetualId := cumulativePnL.Metadata.ClobPair.MustGetPerpetualId() for subaccountId, deltaQuantums := range cumulativePnL.SubaccountPositionSizeDelta { // Get the subaccount and its perpetual positions. subaccount := k.subaccountsKeeper.GetSubaccount(ctx, subaccountId) @@ -819,7 +819,11 @@ func (c *CumulativePnL) AddPnLForTradeWithFilledSubticks( feePpm int32, ) (err error) { // Get the fill quote quantums using the filled subticks and filled quantums. - filledQuoteQuantums, err := getFillQuoteQuantums(c.ClobPair, filledSubticks, filledQuantums) + filledQuoteQuantums, err := getFillQuoteQuantums( + c.Metadata.ClobPair, + filledSubticks, + filledQuantums, + ) if err != nil { return err } @@ -852,7 +856,11 @@ func (c *CumulativePnL) AddPnLForTradeWithFilledQuoteQuantums( feePpm int32, ) (err error) { // Get the fill quote quantums using the mid price subticks and filled quantums. - filledQuoteQuantumsUsingMidPrice, err := getFillQuoteQuantums(c.ClobPair, c.MidPriceSubticks, filledQuantums) + filledQuoteQuantumsUsingMidPrice, err := getFillQuoteQuantums( + c.Metadata.ClobPair, + c.Metadata.MidPrice, + filledQuantums, + ) if err != nil { return err } diff --git a/protocol/x/clob/keeper/mev_test.go b/protocol/x/clob/keeper/mev_test.go index 8d37691d24..02bf872246 100644 --- a/protocol/x/clob/keeper/mev_test.go +++ b/protocol/x/clob/keeper/mev_test.go @@ -27,6 +27,9 @@ import ( ) func TestRecordMevMetrics(t *testing.T) { + // Set the maximum spread to 10%. + keeper.MAX_SPREAD_BEFORE_FALLING_BACK_TO_ORACLE = new(big.Rat).SetFrac64(1, 10) + tests := map[string]struct { // Setup. subaccounts []satypes.Subaccount @@ -993,6 +996,12 @@ func TestRecordMevMetrics(t *testing.T) { uint32(0), metrics.MidPrice, tc.expectedMidPrice, + metrics.OraclePrice, + uint64(100_000_000), + metrics.BestBid, + mock.Anything, + metrics.BestAsk, + mock.Anything, // Validator stats. metrics.ValidatorNumFills, tc.expectedValidatorNumFills, @@ -1014,6 +1023,9 @@ func TestRecordMevMetrics(t *testing.T) { } func TestGetMidPrices(t *testing.T) { + // Set the maximum spread to 1%. + keeper.MAX_SPREAD_BEFORE_FALLING_BACK_TO_ORACLE = new(big.Rat).SetFrac64(1, 100) + tests := map[string]struct { // Setup. perpetuals []perptypes.Perpetual @@ -1037,13 +1049,13 @@ func TestGetMidPrices(t *testing.T) { }, orders: []types.Order{ // Bid - constants.Order_Carl_Num0_Id0_Clob0_Buy1BTC_Price49500_GTB10, + constants.Order_Carl_Num0_Id0_Clob0_Buy1BTC_Price49800_GTB10, // Ask constants.Order_Dave_Num0_Id0_Clob0_Sell1BTC_Price50000_GTB10, }, expectedMidPrices: map[types.ClobPairId]types.Subticks{ - constants.ClobPair_Btc.GetClobPairId(): 49_750_000_000, // 49500 + (50000 - 49500) / 2 + constants.ClobPair_Btc.GetClobPairId(): 49_900_000_000, // 49800 + (50000 - 49800) / 2 }, }, "can get mid prices from one orderbook when there are multiple orders on the same level": { @@ -1059,15 +1071,15 @@ func TestGetMidPrices(t *testing.T) { }, orders: []types.Order{ // Bid - constants.Order_Carl_Num0_Id0_Clob0_Buy1BTC_Price49500_GTB10, - constants.Order_Carl_Num0_Id3_Clob0_Buy025BTC_Price49500, + constants.Order_Carl_Num0_Id0_Clob0_Buy1BTC_Price49800_GTB10, + constants.Order_Carl_Num0_Id3_Clob0_Buy025BTC_Price49800, // Ask constants.Order_Dave_Num0_Id0_Clob0_Sell1BTC_Price50000_GTB10, constants.Order_Dave_Num0_Id1_Clob0_Sell025BTC_Price50000_GTB11, }, expectedMidPrices: map[types.ClobPairId]types.Subticks{ - constants.ClobPair_Btc.GetClobPairId(): 49_750_000_000, // 49500 + (50000 - 49500) / 2 + constants.ClobPair_Btc.GetClobPairId(): 49_900_000_000, // 49800 + (50000 - 49800) / 2 }, }, "can get mid prices from one orderbook when there are multiple price levels": { @@ -1084,14 +1096,14 @@ func TestGetMidPrices(t *testing.T) { orders: []types.Order{ // Bid constants.Order_Carl_Num0_Id4_Clob0_Buy05BTC_Price40000, - constants.Order_Carl_Num0_Id0_Clob0_Buy1BTC_Price49500_GTB10, + constants.Order_Carl_Num0_Id0_Clob0_Buy1BTC_Price49800_GTB10, // Ask constants.Order_Dave_Num0_Id0_Clob0_Sell1BTC_Price50000_GTB10, constants.Order_Dave_Num0_Id1_Clob0_Sell025BTC_Price50500_GTB11, }, expectedMidPrices: map[types.ClobPairId]types.Subticks{ - constants.ClobPair_Btc.GetClobPairId(): 49_750_000_000, // 49500 + (50000 - 49500) / 2 + constants.ClobPair_Btc.GetClobPairId(): 49_900_000_000, // 49800 + (50000 - 49800) / 2 }, }, "can get mid prices from multiple orderbooks": { @@ -1109,19 +1121,19 @@ func TestGetMidPrices(t *testing.T) { }, orders: []types.Order{ // Bid - constants.Order_Carl_Num0_Id0_Clob0_Buy1BTC_Price49500_GTB10, + constants.Order_Carl_Num0_Id0_Clob0_Buy1BTC_Price49800_GTB10, constants.Order_Carl_Num0_Id4_Clob1_Buy01ETH_Price3000, // Ask constants.Order_Dave_Num0_Id0_Clob0_Sell1BTC_Price50000, - constants.Order_Dave_Num0_Id4_Clob1_Sell1ETH_Price3030, + constants.Order_Dave_Num0_Id4_Clob1_Sell1ETH_Price3020, }, expectedMidPrices: map[types.ClobPairId]types.Subticks{ - constants.ClobPair_Btc.GetClobPairId(): 49_750_000_000, // 49500 + (50000 - 49500) / 2 - constants.ClobPair_Eth.GetClobPairId(): 3_015_000_000, // 3000 + (3030 - 3000) / 2 + constants.ClobPair_Btc.GetClobPairId(): 49_900_000_000, // 49800 + (50000 - 49800) / 2 + constants.ClobPair_Eth.GetClobPairId(): 3_010_000_000, // 3000 + (3020 - 3000) / 2 }, }, - "skips orderbooks that are empty": { + "fallback to oracle price for orderbooks that are empty": { perpetuals: []perptypes.Perpetual{ constants.BtcUsd_20PercentInitial_10PercentMaintenance, constants.EthUsd_20PercentInitial_10PercentMaintenance, @@ -1136,7 +1148,7 @@ func TestGetMidPrices(t *testing.T) { constants.ClobPair_Btc.GetClobPairId(): 50_000_000_000, }, }, - "skips orderbooks with missing best bid": { + "fallback to oracle price for orderbooks with missing best bid": { perpetuals: []perptypes.Perpetual{ constants.BtcUsd_20PercentInitial_10PercentMaintenance, }, @@ -1158,7 +1170,7 @@ func TestGetMidPrices(t *testing.T) { constants.ClobPair_Btc.GetClobPairId(): 50_000_000_000, }, }, - "skips orderbooks with missing best ask": { + "fallback to oracle price for orderbooks with missing best ask": { perpetuals: []perptypes.Perpetual{ constants.BtcUsd_20PercentInitial_10PercentMaintenance, }, @@ -1180,6 +1192,33 @@ func TestGetMidPrices(t *testing.T) { constants.ClobPair_Btc.GetClobPairId(): 50_000_000_000, }, }, + "fallback to oracle price for orderbooks with spread >= 1%": { + perpetuals: []perptypes.Perpetual{ + constants.BtcUsd_20PercentInitial_10PercentMaintenance, + constants.EthUsd_20PercentInitial_10PercentMaintenance, + }, + subaccounts: []satypes.Subaccount{ + constants.Carl_Num0_100000USD, + constants.Dave_Num0_500000USD, + }, + clobPairs: []types.ClobPair{ + constants.ClobPair_Btc, + constants.ClobPair_Eth, + }, + orders: []types.Order{ + // Bid + constants.Order_Carl_Num0_Id0_Clob0_Buy1BTC_Price49500_GTB10, + constants.Order_Carl_Num0_Id4_Clob1_Buy01ETH_Price3000, + // Ask + constants.Order_Dave_Num0_Id0_Clob0_Sell1BTC_Price50000, // Spread > 1% + constants.Order_Dave_Num0_Id4_Clob1_Sell1ETH_Price3030, // Spread == 1% + }, + + expectedMidPrices: map[types.ClobPairId]types.Subticks{ + constants.ClobPair_Btc.GetClobPairId(): 50_000_000_000, + constants.ClobPair_Eth.GetClobPairId(): 3_000_000_000, + }, + }, } for name, tc := range tests { @@ -1248,24 +1287,23 @@ func TestGetMidPrices(t *testing.T) { require.NoError(t, err) } - clobMidPrices, clobPairs := ks.ClobKeeper.GetClobMetadata(ctx) + clobMetadata := ks.ClobKeeper.GetClobMetadata(ctx) blockProposerPnL, validatorPnL := ks.ClobKeeper.InitializeCumulativePnLs( ctx, ks.PerpetualsKeeper, - clobMidPrices, - clobPairs, + clobMetadata, ) for clobPairId, expectedMidPrice := range tc.expectedMidPrices { require.Equal( t, expectedMidPrice, - blockProposerPnL[clobPairId].MidPriceSubticks, + blockProposerPnL[clobPairId].Metadata.MidPrice, ) require.Equal( t, expectedMidPrice, - validatorPnL[clobPairId].MidPriceSubticks, + validatorPnL[clobPairId].Metadata.MidPrice, ) } }) diff --git a/protocol/x/clob/memclob/memclob.go b/protocol/x/clob/memclob/memclob.go index 662fc530fb..e81f1ae0d4 100644 --- a/protocol/x/clob/memclob/memclob.go +++ b/protocol/x/clob/memclob/memclob.go @@ -2084,14 +2084,18 @@ func (m *MemClobPriceTimePriority) maybeCancelReduceOnlyOrders( // GetMidPrice returns the mid price of the orderbook for the given clob pair // and whether or not it exists. +// This function also returns the best bid and best ask orders, if they exist. func (m *MemClobPriceTimePriority) GetMidPrice( ctx sdk.Context, clobPairId types.ClobPairId, ) ( - subticks types.Subticks, + midPrice types.Subticks, + bestBid types.Order, + bestAsk types.Order, exists bool, ) { - subticks, exists = m.openOrders.orderbooksMap[clobPairId].GetMidPrice() + orderbook := m.openOrders.orderbooksMap[clobPairId] + midPrice, exists = orderbook.GetMidPrice() if !exists { telemetry.IncrCounterWithLabels( []string{types.ModuleName, metrics.MissingMidPrice, metrics.Count}, @@ -2104,7 +2108,14 @@ func (m *MemClobPriceTimePriority) GetMidPrice( }, ) } - return subticks, exists + + if levelOrder, found := m.openOrders.getBestOrderOnSide(orderbook, true); found { + bestBid = levelOrder.Value.Order + } + if levelOrder, found := m.openOrders.getBestOrderOnSide(orderbook, false); found { + bestAsk = levelOrder.Value.Order + } + return midPrice, bestBid, bestAsk, exists } // getImpactPriceSubticks returns the impact ask or bid price (in subticks), given the clob pair diff --git a/protocol/x/clob/types/memclob.go b/protocol/x/clob/types/memclob.go index b3941fa00a..352c741787 100644 --- a/protocol/x/clob/types/memclob.go +++ b/protocol/x/clob/types/memclob.go @@ -131,7 +131,9 @@ type MemClob interface { ctx sdk.Context, clobPairId ClobPairId, ) ( - subticks Subticks, + midPrice Subticks, + bestBid Order, + bestAsk Order, exists bool, ) } diff --git a/protocol/x/clob/types/orderbook.go b/protocol/x/clob/types/orderbook.go index 905e5483aa..bb5df5e2af 100644 --- a/protocol/x/clob/types/orderbook.go +++ b/protocol/x/clob/types/orderbook.go @@ -94,7 +94,10 @@ func (ob *Orderbook) GetSide(isBuy bool) map[Subticks]*Level { } // GetMidPrice returns the mid price of the orderbook and whether or not it exists. -func (ob *Orderbook) GetMidPrice() (Subticks, bool) { +func (ob *Orderbook) GetMidPrice() ( + midPrice Subticks, + exists bool, +) { if ob.BestBid == 0 || ob.BestAsk == math.MaxUint64 { return 0, false }