From 08ef538c4f40b9e20351f0ae7aa038725da6c936 Mon Sep 17 00:00:00 2001 From: Priya Date: Mon, 18 Aug 2025 14:35:49 +0200 Subject: [PATCH 1/3] test: add default mocks (#18384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds a default mocks setup for all tests. Each test now automatically includes a predefined set of default mocks. Test-specific mocks can be added on top of these defaults. When a test mocks an endpoint that is also in the default set, the test’s mock takes precedence. This ensures consistent baseline mocks across the suite while still allowing per-test customization. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: [MMQA-851](https://consensyssoftware.atlassian.net/browse/MMQA-851) ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [MMQA-851]: https://consensyssoftware.atlassian.net/browse/MMQA-851?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- e2e/api-mocking/default-mocks.js | 380 ++ e2e/api-mocking/mock-e2e-allowlist.js | 19 +- .../mock-responses/accounts-api-responses.ts | 1208 ++++++ .../mock-responses/feature-flags-mocks.ts | 164 + .../staking-api-responses-mocks.ts | 3463 +++++++++++++++++ .../mock-responses/token-api-responses.ts | 293 ++ e2e/framework/fixtures/FixtureHelper.ts | 49 +- .../assets/multichain/asset-list.spec.ts | 5 +- 8 files changed, 5567 insertions(+), 14 deletions(-) create mode 100644 e2e/api-mocking/default-mocks.js create mode 100644 e2e/api-mocking/mock-responses/accounts-api-responses.ts create mode 100644 e2e/api-mocking/mock-responses/feature-flags-mocks.ts create mode 100644 e2e/api-mocking/mock-responses/staking-api-responses-mocks.ts create mode 100644 e2e/api-mocking/mock-responses/token-api-responses.ts diff --git a/e2e/api-mocking/default-mocks.js b/e2e/api-mocking/default-mocks.js new file mode 100644 index 00000000000..6a58515d0c5 --- /dev/null +++ b/e2e/api-mocking/default-mocks.js @@ -0,0 +1,380 @@ +/** + * Default mock responses for common MetaMask mobile endpoints + * These are used as fallbacks when no specific mock is provided + */ + +import { getAuthMocks } from './mock-responses/auth-mocks'; +import { SWAPS_FEATURE_FLAG_RESPONSE } from './mock-responses/feature-flags-mocks'; +import { + ACCOUNTS_API_ACTIVE_NETWORKS_RESPONSE, + ACCOUNTS_API_TRANSACTIONS_RESPONSE, + ACTIVE_NETWORKS_RESPONSE, +} from './mock-responses/accounts-api-responses'; +import { + POOLED_STAKING_VAULT_RESPONSE, + STAKING_API_LENDING_RESPONSE, +} from './mock-responses/staking-api-responses-mocks'; +import { TOKEN_API_TOKENS_RESPONSE } from './mock-responses/token-api-responses'; + +// Get auth mocks +const authMocks = getAuthMocks(); + +export const DEFAULT_MOCKS = { + GET: [ + // Auth mocks + ...authMocks.GET, + { + urlEndpoint: + 'https://dapp-scanning.api.cx.metamask.io/v2/scan?url=www.google.com', + responseCode: 200, + response: { + domainName: 'google.com', + recommendedAction: 'NONE', + }, + }, + { + urlEndpoint: + 'https://dapp-scanning.api.cx.metamask.io/v2/scan?url=google.com', + responseCode: 200, + response: { + domainName: 'google.com', + recommendedAction: 'NONE', + }, + }, + { + urlEndpoint: + 'https://dapp-scanning.api.cx.metamask.io/v2/scan?url=localhost', + responseCode: 200, + response: { + domainName: 'localhost', + recommendedAction: 'NONE', + }, + }, + { + urlEndpoint: + 'https://dapp-scanning.api.cx.metamask.io/v2/scan?url=verify.walletconnect.com', + responseCode: 200, + response: { + domainName: 'verify.walletconnect.com', + recommendedAction: 'NONE', + }, + }, + { + urlEndpoint: + 'https://dapp-scanning.api.cx.metamask.io/v2/scan?url=portfolio.metamask.io', + responseCode: 200, + response: { + domainName: 'portfolio.metamask.io', + recommendedAction: 'NONE', + }, + }, + { + urlEndpoint: + 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=usd&tsyms=usd', + responseCode: 200, + response: { + USD: { + USD: 1, + }, + }, + }, + { + urlEndpoint: + 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=ETH&tsyms=usd', + responseCode: 200, + response: { + ETH: { + USD: 3807.92, + }, + }, + }, + { + urlEndpoint: 'https://security-alerts.api.cx.metamask.io/validate/0x539', + responseCode: 200, + response: { + block: null, + result_type: 'Benign', + reason: '', + description: '', + features: [], + }, + }, + { + urlEndpoint: + 'https://user-storage.api.cx.metamask.io/api/v1/userstorage/addressBook', + responseCode: 200, + response: [], + }, + { + urlEndpoint: + 'https://authentication.api.cx.metamask.io/api/v2/nonce?identifier=0x030b4cfd21a0a0aca69b038e6d268f8eb83a8ea43610aabcd4ff6a19d13e0d10ba', + responseCode: 200, + response: { + nonce: 'gxTzW7WWhXSLlbCg', + identifier: + '0x030b4cfd21a0a0aca69b038e6d268f8eb83a8ea43610aabcd4ff6a19d13e0d10ba', + expires_in: 300, + }, + }, + { + urlEndpoint: + 'https://authentication.api.cx.metamask.io/api/v2/nonce?identifier=0x0306d1490b98c04a4265247ef4a4337e84be3430221a2156804bab2387bb1169b8', + responseCode: 200, + response: { + nonce: '9kFZyPPFkud2z4Ug', + identifier: + '0x0306d1490b98c04a4265247ef4a4337e84be3430221a2156804bab2387bb1169b8', + expires_in: 300, + }, + }, + { + urlEndpoint: + 'https://api.web3auth.io/fnd-service/node-details?network=sapphire_mainnet&verifier=auth-connection-id&verifierId=user-id&keyType=secp256k1&sigType=ecdsa-secp256k1', + responseCode: 200, + response: { + nodeDetails: { + currentEpoch: '1', + torusNodeEndpoints: [ + 'https://node-1.node.web3auth.io/sss/jrpc', + 'https://node-2.node.web3auth.io/sss/jrpc', + 'https://node-3.node.web3auth.io/sss/jrpc', + 'https://node-4.node.web3auth.io/sss/jrpc', + 'https://node-5.node.web3auth.io/sss/jrpc', + ], + torusNodeSSSEndpoints: [ + 'https://node-1.node.web3auth.io/sss/jrpc', + 'https://node-2.node.web3auth.io/sss/jrpc', + 'https://node-3.node.web3auth.io/sss/jrpc', + 'https://node-4.node.web3auth.io/sss/jrpc', + 'https://node-5.node.web3auth.io/sss/jrpc', + ], + torusNodeRSSEndpoints: [ + 'https://node-1.node.web3auth.io/rss', + 'https://node-2.node.web3auth.io/rss', + 'https://node-3.node.web3auth.io/rss', + 'https://node-4.node.web3auth.io/rss', + 'https://node-5.node.web3auth.io/rss', + ], + torusNodeTSSEndpoints: [ + 'https://node-1.node.web3auth.io/tss', + 'https://node-2.node.web3auth.io/tss', + 'https://node-3.node.web3auth.io/tss', + 'https://node-4.node.web3auth.io/tss', + 'https://node-5.node.web3auth.io/tss', + ], + torusIndexes: [1, 2, 3, 4, 5], + torusNodePub: [ + { + X: 'e0925898fee0e9e941fdca7ee88deec99939ae9407e923535c4d4a3a3ff8b052', + Y: '54b9fea924e3f3e40791f9987f4234ae4222412d65b74068032fa5d8b63375c1', + }, + { + X: '9124cf1e280aab32ba50dffd2de81cecabc13d82d2c1fe9de82f3b3523f9b637', + Y: 'fca939a1ceb42ce745c55b21ef094f543b457630cb63a94ef4f1afeee2b1f107', + }, + { + X: '555f681a63d469cc6c3a58a97e29ebd277425f0e6159708e7c7bf05f18f89476', + Y: '606f2bcc0884fa5b64366fc3e8362e4939841b56acd60d5f4553cf36b891ac4e', + }, + { + X: '2b5f58d8e340f1ab922e89b3a69a68930edfe51364644a456335e179bc130128', + Y: '4b4daa05939426e3cbe7d08f0e773d2bf36f64c00d04620ee6df2a7af4d2247', + }, + { + X: '3ecbb6a68afe72cf34ec6c0a12b5cb78a0d2e83ba402983b6adbc5f36219861a', + Y: 'dc1031c5cc8f0472bd521a62a64ebca9e163902c247bf05937daf4ae835091e4', + }, + ], + }, + success: true, + }, + }, + { + urlEndpoint: 'https://accounts.api.cx.metamask.io/v1/supportedNetworks', + responseCode: 200, + response: { + fullSupport: [1, 137, 56, 59144, 8453, 10, 42161, 534352, 1329], + partialSupport: { balances: [42220, 43114] }, + }, + }, + { + urlEndpoint: 'https://swap.dev-api.cx.metamask.io/featureFlags', + responseCode: 200, + response: SWAPS_FEATURE_FLAG_RESPONSE, + }, + { + urlEndpoint: 'https://swap.api.cx.metamask.io/featureFlags', + responseCode: 200, + response: SWAPS_FEATURE_FLAG_RESPONSE, + }, + { + urlEndpoint: + 'https://accounts.api.cx.metamask.io/v2/activeNetworks?accountIds=eip155%3A0%3A0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + responseCode: 200, + response: ACCOUNTS_API_ACTIVE_NETWORKS_RESPONSE, + }, + { + urlEndpoint: + 'https://accounts.api.cx.metamask.io/v2/accounts/0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3/balances?networks=1', + responseCode: 200, + response: { + count: 1, + balances: [ + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + type: 'native', + timestamp: '2015-07-30T15:26:13.000Z', + decimals: 18, + chainId: 1, + balance: '0.000000000000000000', + }, + ], + unprocessedNetworks: [], + }, + }, + { + urlEndpoint: + 'https://accounts.api.cx.metamask.io/v2/accounts/0xaa4179e7f103701e904d27df223a39aa9c27405a/balances?networks=1%2C59144%2C8453%2C42161%2C56%2C10%2C137', + responseCode: 200, + response: { count: 0, balances: [], unprocessedNetworks: [] }, + }, + { + urlEndpoint: + 'https://accounts.api.cx.metamask.io/v1/accounts/0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3/transactions?networks=0x1,0x89,0x38,0xe708,0x2105,0xa,0xa4b1,0x82750,0x531&sortDirection=DESC', + responseCode: 200, + response: ACCOUNTS_API_TRANSACTIONS_RESPONSE, + }, + { + urlEndpoint: + 'https://staking.api.cx.metamask.io/v1/pooled-staking/vault/1/apys?days=365&order=desc', + responseCode: 200, + response: POOLED_STAKING_VAULT_RESPONSE, + }, + { + urlEndpoint: 'https://staking.api.cx.metamask.io/v1/lending/markets', + responseCode: 200, + response: STAKING_API_LENDING_RESPONSE, + }, + { + urlEndpoint: + 'https://staking.api.cx.metamask.io/v1/pooled-staking/eligibility?addresses=0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + responseCode: 200, + response: { eligible: true }, + }, + { + urlEndpoint: + 'https://staking.api.cx.metamask.io/v1/lending/positions/0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + responseCode: 200, + response: { positions: [] }, + }, + { + urlEndpoint: + 'https://staking.api.cx.metamask.io/v1/pooled-staking/vault/1', + responseCode: 200, + response: { + apy: '2.423922825407589424778761061946903', + capacity: + '115792089237316195423570985008687907853269984665640564039457584007913129639935', + feePercent: 1500, + totalAssets: '34582364608391084442226', + vaultAddress: '0x4fef9d741011476750a243ac70b9789a63dd47df', + }, + }, + { + urlEndpoint: + 'https://staking.api.cx.metamask.io/v1/pooled-staking/vault/1/apys/averages', + responseCode: 200, + response: { + oneDay: '2.160630689308144746', + oneWeek: '2.42203859587349324429', + oneMonth: '2.49056583176788989407', + threeMonths: '2.52669759044094515534', + sixMonths: '2.65550671135719381941', + oneYear: '2.6612714152041561994', + }, + }, + { + urlEndpoint: + 'https://staking.api.cx.metamask.io/v1/pooled-staking/stakes/1?accounts=0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + responseCode: 200, + response: { + accounts: [ + { + account: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + lifetimeRewards: '0', + assets: '0', + exitRequests: [], + }, + ], + exchangeRate: '1.034162108591709262', + }, + }, + { + urlEndpoint: 'https://on-ramp.api.cx.metamask.io/geolocation', + responseCode: 200, + response: 'US', + }, + { + urlEndpoint: + 'https://token.api.cx.metamask.io/tokens/1337?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false', + responseCode: 200, + response: TOKEN_API_TOKENS_RESPONSE, + }, + { + urlEndpoint: + 'https://defiadapters.api.cx.metamask.io/positions/0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + responseCode: 200, + response: { + data: [], + }, + }, + { + urlEndpoint: + 'https://dweb.link/ipfs/Qmaisz6NMhDB51cCvNWa1GMS7LU1pAxdF4Ld6Ft9kZEP2a#x-ipfs-companion-no-redirect', + responseCode: 200, + response: 'Hello from IPFS Gateway Checker', + }, + ], + POST: [ + // Auth mocks + ...authMocks.POST, + { + urlEndpoint: 'https://api.segment.io/v1/track', + responseCode: 200, + response: { + success: true, + }, + }, + { + urlEndpoint: 'https://api.mixpanel.com/track', + responseCode: 200, + response: { + status: 1, + }, + }, + { + urlEndpoint: 'https://token-api.metaswap.codefi.network/tokens', + responseCode: 200, + response: [], + }, + { + urlEndpoint: 'https://security-alerts.api.cx.metamask.io/validate', + responseCode: 200, + response: { + flagAsDangerous: 0, + }, + }, + { + urlEndpoint: + 'https://pulse.walletconnect.org/batch?projectId=017a80231854c3b1c56df7bb46bba859&st=events_sdk&sv=js-2.19.2&sp=desktop', + responseCode: 200, + response: {}, + }, + ], + PUT: [], + DELETE: [], + PATCH: [], +}; diff --git a/e2e/api-mocking/mock-e2e-allowlist.js b/e2e/api-mocking/mock-e2e-allowlist.js index b67cb2a0499..165ecad4d9c 100644 --- a/e2e/api-mocking/mock-e2e-allowlist.js +++ b/e2e/api-mocking/mock-e2e-allowlist.js @@ -2,27 +2,28 @@ // This list is temporary and the goal is to reduce it to 0, meaning all requests are mocked in our e2e tests. export const ALLOWLISTED_HOSTS = [ - 'localhost', + '0.0.0.0', '127.0.0.1', + 'localhost', '10.0.2.2', // Android emulator host 'api.tenderly.co', 'rpc.tenderly.co', + 'virtual.mainnet.rpc.tenderly.co', ]; export const ALLOWLISTED_URLS = [ // Temporarily allow existing live requests during migration 'https://client-config.api.cx.metamask.io/v1/flags?client=mobile&distribution=main&environment=dev', 'https://staking.api.cx.metamask.io/v1/lending/1/markets', - 'https://staking.api.cx.metamask.io/v1/pooled-staking/stakes/1?accounts=0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', - 'https://staking.api.cx.metamask.io/v1/pooled-staking/eligibility?addresses=0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', - 'https://staking.api.cx.metamask.io/v1/pooled-staking/vault/1', - 'https://staking.api.cx.metamask.io/v1/lending/markets', - 'https://staking.api.cx.metamask.io/v1/pooled-staking/vault/1/apys?days=365&order=desc', - 'https://staking.api.cx.metamask.io/v1/pooled-staking/vault/1/apys/averages', - 'https://staking.api.cx.metamask.io/v1/lending/positions/0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', - 'https://mainnet.infura.io/v3/8f4bc0ed77aa4a2c886a4d929754f414', + 'https://staking.api.cx.metamask.io/v1/lending/positions/CEQ87PmqFPA8cajAXYVrFT2FQobRrAT4Wd53FvfgYrrd', 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=btc%2Csol&tsyms=usd', 'https://pulse.walletconnect.org/batch?projectId=e698cc28a9e75eb175ae3c991ac7eb2a&st=events_sdk&sv=js-2.19.2&sp=desktop', 'https://clients3.google.com/generate_204', + 'https://api.avax.network/ext/bc/C/rpc', 'https://security-alerts.api.cx.metamask.io/validate/0x539', + 'https://token.api.cx.metamask.io/tokens/1?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false', + // this should be fixed in code to remove the double slash before transactions, mock without double slash already in the defaults + 'https://accounts.api.cx.metamask.io/v1/accounts/0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3//transactions?networks=0x1,0x89,0x38,0xe708,0x2105,0xa,0xa4b1,0x82750,0x531&sortDirection=DESC', + // this should be fixed in code to remove the double slash before balances, mock without double slash already in the default mocks + 'https://accounts.api.cx.metamask.io/v2/accounts//balances?networks=1', ]; diff --git a/e2e/api-mocking/mock-responses/accounts-api-responses.ts b/e2e/api-mocking/mock-responses/accounts-api-responses.ts new file mode 100644 index 00000000000..9f32c9962ed --- /dev/null +++ b/e2e/api-mocking/mock-responses/accounts-api-responses.ts @@ -0,0 +1,1208 @@ +export const ACCOUNTS_API_TRANSACTIONS_RESPONSE = { + data: [ + { + hash: '0x027c29d4b64ae91d6c2cc6f72a5b1885e5ab17ef1b6602452448043e13f6770d', + timestamp: '2025-07-26T19:31:31.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138979757, + blockHash: + '0x2c8759771b01cb297c89dfa7b3cdd816848a9a88fb3bdbc810c7eaf9b3275609', + gas: 5000000, + gasUsed: 2131592, + gasPrice: '1000933', + effectiveGasPrice: '1000933', + nonce: 3130, + cumulativeGasUsed: 7434815, + methodId: '0x441ff998', + value: '0', + to: '0xfd59cd78fa951c750ef5d129390e16807304264a', + from: '0x085cc749b6f9334fd94abdc473abefdbc790f057', + isError: false, + valueTransfers: [ + { + from: '0x085cc749b6f9334fd94abdc473abefdbc790f057', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0xfd59cd78fa951c750ef5d129390e16807304264a', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x33e808a4107cf537b858c67a4b267ddf4e1e08fc6ca0476ee585a7855587b1f5', + timestamp: '2025-07-26T19:31:30.000Z', + chainId: 137, + accountId: 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 74444312, + blockHash: + '0x1ce60ca3603baae547a5f47beaf0d67117d462e978e55ef9e28f5aba01927491', + gas: 816820, + gasUsed: 809230, + gasPrice: '28600000085', + effectiveGasPrice: '28600000085', + nonce: 4557, + cumulativeGasUsed: 14287546, + methodId: '0xa06c1a33', + value: '0', + to: '0x5ca2ad27e80a4d1fc814b12afc0478be2b111ae0', + from: '0xf9caf760edc7870f0e6dc1fa87b560e447aabc97', + isError: false, + valueTransfers: [ + { + from: '0xe7804c37c13166ff0b37f5ae0bb07a3aebb6e245', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: '1', + contractAddress: '0x5ca2ad27e80a4d1fc814b12afc0478be2b111ae0', + symbol: 'Swap your Voucher on ethers.bio.link', + name: '✅ ETHERS VOUCHER', + transferType: 'erc20', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x1659dd108b18824828631d4dcb501fdd961d45a0b479b9b4c71b88746c775a3e', + timestamp: '2025-07-26T19:31:27.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138979755, + blockHash: + '0x287f0caa3a603f67b08e2b0fd6cb26b014d209b7fe93a51e9333a43f0efca8d9', + gas: 1019124, + gasUsed: 1009941, + gasPrice: '1211023', + effectiveGasPrice: '1211023', + nonce: 9041, + cumulativeGasUsed: 2275038, + methodId: '0xa06c1a33', + value: '0', + to: '0xd9d2cb4ee2388c1b453f572f9ccbd4419d73c8c8', + from: '0x3e26c186419b52d70f77d8ffb8eb2b1615adcd2d', + isError: false, + valueTransfers: [ + { + from: '0x3e26c186419b52d70f77d8ffb8eb2b1615adcd2d', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0xd9d2cb4ee2388c1b453f572f9ccbd4419d73c8c8', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x5dec791534a92a4a170d193f5d9e77e209c574f36e4de36b47fac8f276a5ac57', + timestamp: '2025-07-26T19:31:25.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138979754, + blockHash: + '0xf2f146ecebe791b7ad372291b0e97df63e10428ff155fd91480f3f593b05622c', + gas: 5000000, + gasUsed: 2131592, + gasPrice: '1000932', + effectiveGasPrice: '1000932', + nonce: 3129, + cumulativeGasUsed: 9392520, + methodId: '0x441ff998', + value: '0', + to: '0xfd59cd78fa951c750ef5d129390e16807304264a', + from: '0x085cc749b6f9334fd94abdc473abefdbc790f057', + isError: false, + valueTransfers: [ + { + from: '0x085cc749b6f9334fd94abdc473abefdbc790f057', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0xfd59cd78fa951c750ef5d129390e16807304264a', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x155b33f5a84c27b1af5d3a712d67f1bec2561d3bd792a1928cc0086e0561cec1', + timestamp: '2025-07-26T19:31:21.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138979752, + blockHash: + '0xb0f384961a8d309c2f376138038dfc759a569d315b5465a7d92fc462aa14fcc0', + gas: 5000000, + gasUsed: 2131592, + gasPrice: '1000929', + effectiveGasPrice: '1000929', + nonce: 3128, + cumulativeGasUsed: 7584613, + methodId: '0x441ff998', + value: '0', + to: '0xfd59cd78fa951c750ef5d129390e16807304264a', + from: '0x085cc749b6f9334fd94abdc473abefdbc790f057', + isError: false, + valueTransfers: [ + { + from: '0x085cc749b6f9334fd94abdc473abefdbc790f057', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0xfd59cd78fa951c750ef5d129390e16807304264a', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x527290a4001ab0dc13338af016c83dbf61c3e200b29d984187aff5149aa1f0fc', + timestamp: '2025-07-26T19:31:17.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138979750, + blockHash: + '0x73d83bb2e7ec4b1da7a6acf13d768aafe75a648fa2149fed204a2e0b87936728', + gas: 4699788, + gasUsed: 4439587, + gasPrice: '1172', + effectiveGasPrice: '1172', + nonce: 46241, + cumulativeGasUsed: 19957046, + methodId: '0x729ad39e', + value: '0', + to: '0xef1cee46176605fe90519981db858d01d1c4745f', + from: '0xc070ccd5c2c4d824f0c1cf50675b8ac4feb4e0e2', + isError: false, + valueTransfers: [ + { + from: '0xef1cee46176605fe90519981db858d01d1c4745f', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0xef1cee46176605fe90519981db858d01d1c4745f', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0xcdd24e1414c8fa3fb6e8148497b96cd476020bd0ee90dfc31aca5126820e8fa5', + timestamp: '2025-07-26T19:30:47.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 23005418, + blockHash: + '0x47fe85c31e198367d060512b5bf85b446f46c58a04fb9b7760f6fb97716dc4ca', + gas: 200000, + gasUsed: 61418, + gasPrice: '248473479', + effectiveGasPrice: '248473479', + nonce: 597, + cumulativeGasUsed: 18106189, + methodId: '0xa9059cbb', + value: '0', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + from: '0x8b4ea4f8a25ed1b7b18044bb1a2bec9425603572', + isError: false, + valueTransfers: [ + { + from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + to: '0x8b4ea4f8a25ed1b7b18044bb1a2bec9425603572', + amount: '120000', + decimal: 6, + contractAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USD Coin', + transferType: 'erc20', + }, + ], + logs: [], + transactionProtocol: 'ERC_20', + transactionCategory: 'TRANSFER', + transactionType: 'ERC_20_TRANSFER', + readable: 'Token: Transfer', + }, + { + hash: '0x942a03ff7599f739e00a19c991130a0b4bebfebcfd24b31b5a007e6047272166', + timestamp: '2025-07-23T23:54:55.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138858059, + blockHash: + '0xcb47e8545ec8432a30ef70b659d6de0ac4d48471356a58b3e35d433a7aa09702', + gas: 5000000, + gasUsed: 2368988, + gasPrice: '1028339', + effectiveGasPrice: '1028339', + nonce: 288, + cumulativeGasUsed: 15167965, + methodId: '0x441ff998', + value: '0', + to: '0x74f59dfa8d9e8ef9dfca86c6a5e7e617c26f52ba', + from: '0xe2def864f6def93144144b8f7a1d29de46ba7e56', + isError: false, + valueTransfers: [ + { + from: '0xe2def864f6def93144144b8f7a1d29de46ba7e56', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0x74f59dfa8d9e8ef9dfca86c6a5e7e617c26f52ba', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x18e6db5a5f2ce5af3f37dbe9d6fb055e03d5e1188f3ad9df8fd934d80e9f7cdd', + timestamp: '2025-07-23T23:54:51.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138858057, + blockHash: + '0xd7959aad3a786095de7ede87a951214281e2f62bc62081114938cba5ce1567d7', + gas: 5000000, + gasUsed: 2368988, + gasPrice: '1028409', + effectiveGasPrice: '1028409', + nonce: 287, + cumulativeGasUsed: 3267144, + methodId: '0x441ff998', + value: '0', + to: '0x74f59dfa8d9e8ef9dfca86c6a5e7e617c26f52ba', + from: '0xe2def864f6def93144144b8f7a1d29de46ba7e56', + isError: false, + valueTransfers: [ + { + from: '0xe2def864f6def93144144b8f7a1d29de46ba7e56', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0x74f59dfa8d9e8ef9dfca86c6a5e7e617c26f52ba', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0xa3d438d71085b354cf2dfd65beef373f616b688f3110d3275201a2e97ceb313d', + timestamp: '2025-07-23T23:54:47.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138858055, + blockHash: + '0x03defe95d5d4b8a57062dd2436e1f23bb2fb9a23fccbf31c0bd23f898d880dc8', + gas: 5000000, + gasUsed: 2368988, + gasPrice: '1028429', + effectiveGasPrice: '1028429', + nonce: 286, + cumulativeGasUsed: 3130984, + methodId: '0x441ff998', + value: '0', + to: '0x74f59dfa8d9e8ef9dfca86c6a5e7e617c26f52ba', + from: '0xe2def864f6def93144144b8f7a1d29de46ba7e56', + isError: false, + valueTransfers: [ + { + from: '0xe2def864f6def93144144b8f7a1d29de46ba7e56', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0x74f59dfa8d9e8ef9dfca86c6a5e7e617c26f52ba', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x7e337b9135cc9408acdd045b743e34f7d4d13ed53d21728b1ceaf2f4de6d83e1', + timestamp: '2025-07-23T23:33:38.000Z', + chainId: 137, + accountId: 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 74329442, + blockHash: + '0x05d4002c3d302306ba0f3b7d5f414fbf950d9615d867f2a405f83ac92aab4949', + gas: 499111, + gasUsed: 415926, + gasPrice: '30000000000', + effectiveGasPrice: '30000000000', + nonce: 3945, + cumulativeGasUsed: 15454145, + methodId: '0x729ad39e', + value: '0', + to: '0xa264885c44a58f20bd0cc27ccec57660d7b98ca1', + from: '0x081b6355a6fd09367b68c54be1848bd30e3d8dff', + isError: false, + valueTransfers: [ + { + from: '0xe7804c37c13166ff0b37f5ae0bb07a3aebb6e245', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: '1000000000000000000', + decimal: 18, + contractAddress: '0xa264885c44a58f20bd0cc27ccec57660d7b98ca1', + symbol: 'WWW.ARBQUEST.LIVE VISIT TO SWAP', + name: '✅ ARB Voucher', + transferType: 'erc20', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x767f0dc01b2d7458f849bd82c680b345daf428c3d2e895902aa9021a65ff3e32', + timestamp: '2025-07-23T23:30:01.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138857312, + blockHash: + '0x3ee254f749296105bfb74c794a6949d875000dc07cebe2b801d0cd4738546d15', + gas: 8051574, + gasUsed: 6655655, + gasPrice: '57741', + effectiveGasPrice: '57741', + nonce: 52069, + cumulativeGasUsed: 17895936, + methodId: '0x729ad39e', + value: '0', + to: '0xe07f9d9bbb7e84da658cfa42cbd71f087c5e87f4', + from: '0x1ce7f95ec52e748e7afa4bca38edc6fef413ab2f', + isError: false, + valueTransfers: [ + { + from: '0xe07f9d9bbb7e84da658cfa42cbd71f087c5e87f4', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0xe07f9d9bbb7e84da658cfa42cbd71f087c5e87f4', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x61b94f12e36e27c0da740073413c855c08e1d64f92e1fe9f111a373bda0e3187', + timestamp: '2025-07-23T23:30:01.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138857312, + blockHash: + '0x3ee254f749296105bfb74c794a6949d875000dc07cebe2b801d0cd4738546d15', + gas: 7953562, + gasUsed: 6574622, + gasPrice: '57553', + effectiveGasPrice: '57553', + nonce: 42649, + cumulativeGasUsed: 24470558, + methodId: '0x729ad39e', + value: '0', + to: '0x2b54f5293b1960de7faabfef6bcf2499a4903861', + from: '0x63b1413fe071cc71bdcdfe0cdaa81c40401088f3', + isError: false, + valueTransfers: [ + { + from: '0x2b54f5293b1960de7faabfef6bcf2499a4903861', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0x2b54f5293b1960de7faabfef6bcf2499a4903861', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x5b6ddb41ede3697c2cfc3b14239ffc443346ec312199e01572acf15e7df00cca', + timestamp: '2025-07-23T23:29:53.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138857308, + blockHash: + '0x8770643b7c0d5067e9e9c5578076fed80f312bf901b4c95016774ef34bfeb48a', + gas: 8246529, + gasUsed: 7790839, + gasPrice: '38535', + effectiveGasPrice: '38535', + nonce: 50879, + cumulativeGasUsed: 13002280, + methodId: '0x729ad39e', + value: '0', + to: '0xe1bf0fda900ed301f370e50a6edcb43580bff2ad', + from: '0x059594bb49c2ed5d99f91ada331803982889fdb7', + isError: false, + valueTransfers: [ + { + from: '0xe1bf0fda900ed301f370e50a6edcb43580bff2ad', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0xe1bf0fda900ed301f370e50a6edcb43580bff2ad', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x2cdfb79de64a220a717c4e3b4f8f6842afa59d7dafeb62ae050e9be0395fe970', + timestamp: '2025-07-23T23:29:47.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138857305, + blockHash: + '0x8bfad84ae6851d3b96cc9870e76f5df3fdb68870baa234a9998a488d8b3b7e2e', + gas: 5329084, + gasUsed: 5034198, + gasPrice: '38650', + effectiveGasPrice: '38650', + nonce: 36595, + cumulativeGasUsed: 14626722, + methodId: '0x729ad39e', + value: '0', + to: '0x5da41596b45526c15ca2b68375437da5ab2c0243', + from: '0xc070ccd5c2c4d824f0c1cf50675b8ac4feb4e0e2', + isError: false, + valueTransfers: [ + { + from: '0x5da41596b45526c15ca2b68375437da5ab2c0243', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0x5da41596b45526c15ca2b68375437da5ab2c0243', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0xdb2c3455378d278bc7ff19ecc00ddbed95940718e4ab1417720444148d7c9796', + timestamp: '2025-07-23T23:29:43.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138857303, + blockHash: + '0x271a1d6ac1425c4bc4e71d8f6e50741070cb8b1e5934267a3fd1c0dcb07f4c25', + gas: 7397881, + gasUsed: 6115200, + gasPrice: '54928', + effectiveGasPrice: '54928', + nonce: 45772, + cumulativeGasUsed: 16006228, + methodId: '0x729ad39e', + value: '0', + to: '0x799f442248fa09b838ea84b5044751ee1d2a4ee5', + from: '0xc53b40ef36693b0d6fe4a17a4b9ce8c40402a8fd', + isError: false, + valueTransfers: [ + { + from: '0x799f442248fa09b838ea84b5044751ee1d2a4ee5', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0x799f442248fa09b838ea84b5044751ee1d2a4ee5', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x5ea58c6b6c7fb83dd6bd1bbb56c063dc98e5dc16e32b4005fdf7ec4e8eb52984', + timestamp: '2025-07-23T23:29:41.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138857302, + blockHash: + '0x91794267a2efd11c974c7abafe9729133c4872a2d3de18e07fad2754bad81b30', + gas: 9189574, + gasUsed: 7596522, + gasPrice: '52521', + effectiveGasPrice: '52521', + nonce: 43312, + cumulativeGasUsed: 35426429, + methodId: '0x729ad39e', + value: '0', + to: '0x0c20980c94ad027e2340d65edb22445b40288fea', + from: '0x7648d50120a436045dcac8d54c2e7acd848e48bb', + isError: false, + valueTransfers: [ + { + from: '0x0c20980c94ad027e2340d65edb22445b40288fea', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0x0c20980c94ad027e2340d65edb22445b40288fea', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x3ebd870467f68fd21877893eb67e3c6ccb25464d01b938b441732ff415547f5c', + timestamp: '2025-04-19T01:41:11.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 22299831, + blockHash: + '0xb8d2f604ca30aeb55c6a765518d3221b42b14ecd9c73936aba872b5880d8cf96', + gas: 21000, + gasUsed: 21000, + gasPrice: '279367455', + effectiveGasPrice: '279367455', + nonce: 2, + cumulativeGasUsed: 24447918, + methodId: null, + value: '53829416400', + to: '0x7052f331db3d924686f08cf47d36d912160d127a', + from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + isError: false, + valueTransfers: [ + { + from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + to: '0x7052f331db3d924686f08cf47d36d912160d127a', + amount: '53829416400', + decimal: 18, + transferType: 'normal', + }, + ], + logs: [], + transactionType: 'STANDARD', + transactionCategory: 'STANDARD', + readable: 'Native Transfer', + }, + { + hash: '0xfc887fc95845e3aa8ae52fc03217dc57c962047846474dd605e006bdd1aa4ff9', + timestamp: '2025-04-11T07:54:11.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 22244333, + blockHash: + '0xfe3c1a87497e1019ca26eaced5fa62f91f0ec4edd3c67092c6e1e2d04fb431a9', + gas: 70018, + gasUsed: 64868, + gasPrice: '661810236', + effectiveGasPrice: '661810236', + nonce: 1, + cumulativeGasUsed: 4683910, + methodId: '0xb88d4fde', + value: '0', + to: '0x6cb26df0c825fece867a84658f87b0ecbcea72f6', + from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + isError: false, + valueTransfers: [ + { + from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + to: '0x5f4a2f10181852b5e46244168f4a8aed00fc8850', + tokenId: '2875', + contractAddress: '0x6cb26df0c825fece867a84658f87b0ecbcea72f6', + transferType: 'erc721', + }, + ], + logs: [], + transactionProtocol: 'ERC_721', + transactionCategory: 'TRANSFER', + transactionType: 'ERC_721_TRANSFER', + readable: 'Nft: Transfer', + }, + { + hash: '0xf72c330a2fdd3354b6b11f9230fd4b245d755b1a96f28ddd8fb46e1b47e91012', + timestamp: '2025-04-11T07:54:11.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 22244333, + blockHash: + '0xfe3c1a87497e1019ca26eaced5fa62f91f0ec4edd3c67092c6e1e2d04fb431a9', + gas: 21000, + gasUsed: 21000, + gasPrice: '3409951183', + effectiveGasPrice: '3409951183', + nonce: 7106, + cumulativeGasUsed: 4619042, + methodId: null, + value: '46338629104248', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + from: '0x9146bc6960a81ce3e60d3a0b51d8a503747c56ef', + isError: false, + valueTransfers: [ + { + from: '0x9146bc6960a81ce3e60d3a0b51d8a503747c56ef', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: '46338629104248', + decimal: 18, + transferType: 'normal', + }, + ], + logs: [], + transactionType: 'STANDARD', + transactionCategory: 'STANDARD', + readable: 'Native Transfer', + }, + { + hash: '0xb86195d482105457978fc9a02de391383f7724d9d219e70e641ccd2c0e6b9864', + timestamp: '2025-03-23T07:59:25.000Z', + chainId: 8453, + accountId: 'eip155:8453:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 27963709, + blockHash: + '0xabb3d7bc9620ea7a73a9e946f2e9e7cec702b40d7d88b14325a2c6e3d471f663', + gas: 173477, + gasUsed: 170953, + gasPrice: '3033731', + effectiveGasPrice: '3033731', + nonce: 38994, + cumulativeGasUsed: 48495543, + methodId: '0x729ad39e', + value: '0', + to: '0x67c9f4737f36a7dec28b4d3f4220c31f3b9aeae6', + from: '0x18a0d23d9bccfb8ef993a1bf226f44e7713eddc8', + isError: false, + valueTransfers: [ + { + from: '0x698dc45e4f10966f6d1d98e3bfd7071d8144c233', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + tokenId: '0', + contractAddress: '0x67c9f4737f36a7dec28b4d3f4220c31f3b9aeae6', + transferType: 'erc721', + }, + ], + logs: [], + transactionProtocol: 'ERC_721', + transactionCategory: 'TRANSFER', + transactionType: 'ERC_721_TRANSFER', + readable: 'Nft: Transfer', + }, + { + hash: '0xf3cc5507af7c8adfbe0483af86f1bffd0c21b44b0d9dea708289c76572bea1af', + timestamp: '2025-03-23T07:57:59.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 22108244, + blockHash: + '0x1168f453d13486ea7a91f42fa2cb077b8f455b5a7f5d4e75a486085ada0c2c02', + gas: 34706, + gasUsed: 29906, + gasPrice: '523379845', + effectiveGasPrice: '523379845', + nonce: 0, + cumulativeGasUsed: 4535331, + methodId: '0xa9059cbb', + value: '0', + to: '0x6b175474e89094c44da98b954eedeac495271d0f', + from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + isError: false, + valueTransfers: [ + { + from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + to: '0x792a83e74d76fcdab681249c82bc8ec6e1aa1111', + amount: '100000000000000000', + decimal: 18, + contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + symbol: 'DAI', + name: 'Dai Stablecoin', + transferType: 'erc20', + }, + ], + logs: [], + transactionProtocol: 'ERC_20', + transactionCategory: 'TRANSFER', + transactionType: 'ERC_20_TRANSFER', + readable: 'Token: Transfer', + }, + { + hash: '0x70b9b7fe330d882cdb49a84a159a167d68a6c4e0c6a38f321c38dfb3c4c792ab', + timestamp: '2025-03-23T07:57:59.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 22108244, + blockHash: + '0x1168f453d13486ea7a91f42fa2cb077b8f455b5a7f5d4e75a486085ada0c2c02', + gas: 21000, + gasUsed: 21000, + gasPrice: '523379845', + effectiveGasPrice: '523379845', + nonce: 2554, + cumulativeGasUsed: 4505425, + methodId: null, + value: '18164420900570', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + from: '0x0004405239ff2d9ce60acc56e0b302a3299c4840', + isError: false, + valueTransfers: [ + { + from: '0x0004405239ff2d9ce60acc56e0b302a3299c4840', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: '18164420900570', + decimal: 18, + transferType: 'normal', + }, + ], + logs: [], + transactionType: 'STANDARD', + transactionCategory: 'STANDARD', + readable: 'Native Transfer', + }, + { + hash: '0x449f6329b17246be05a611ede3761fad67c1f9637357f137b5416bec52389dd7', + timestamp: '2024-06-29T22:34:08.000Z', + chainId: 137, + accountId: 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 58765539, + blockHash: + '0x4a862c753dd066318a69c6b92b0955cee0b30733a95d82a237d9e2293b915a91', + gas: 11000000, + gasUsed: 8919284, + gasPrice: '31500000028', + effectiveGasPrice: '31500000028', + nonce: 9529, + cumulativeGasUsed: 11952907, + methodId: '0xfaf67b43', + value: '0', + to: '0x76f8e90320b64590c1a11f5c63ffbbdc83371279', + from: '0xc078e264196e2233039aaa422d4861090dbfb86a', + isError: false, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '1', + contractAddress: '0x76f8e90320b64590c1a11f5c63ffbbdc83371279', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x6dd52a01691b54d25b6098df1f80e1b181449ec237560054ad1351e176c21406', + timestamp: '2024-06-29T18:27:46.000Z', + chainId: 137, + accountId: 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 58758592, + blockHash: + '0x71b52fd770b65d6ad71a16e729634f1dcac141413e20dd45ebe92ecc313330fd', + gas: 11000000, + gasUsed: 8919488, + gasPrice: '31500000089', + effectiveGasPrice: '31500000089', + nonce: 3456, + cumulativeGasUsed: 14031888, + methodId: '0xfaf67b43', + value: '0', + to: '0xc60ba1c956331d76c8e781a7147519ed0427661a', + from: '0xda3e3f3a946e759d84408462c538e74d4f44ddd0', + isError: false, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '1', + contractAddress: '0xc60ba1c956331d76c8e781a7147519ed0427661a', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x01d737867887caafe0502a329736b543e6828cc30a13853bb73c63cc72bfc6c4', + timestamp: '2024-06-20T18:15:41.000Z', + chainId: 137, + accountId: 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 58399154, + blockHash: + '0x7e2a1cda6cfa5491861f419f067b962b2074c541f51d3caf78ade8d390a4eabc', + gas: 11000000, + gasUsed: 6745986, + gasPrice: '31500000033', + effectiveGasPrice: '31500000033', + nonce: 438, + cumulativeGasUsed: 11201316, + methodId: '0xfaf67b43', + value: '0', + to: '0x33d67d15214129e47e357510a2d0f1a25d66ae9b', + from: '0x02de77e62c6b11c64a76fb7341cd678787f8addd', + isError: false, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '1', + contractAddress: '0x33d67d15214129e47e357510a2d0f1a25d66ae9b', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0xc143c1535a4036180db08cc15d526a5861a4b190ff172796edb8bf762f2ab72d', + timestamp: '2024-06-20T18:14:59.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 20134660, + blockHash: + '0x0432270a0889ab380c04738cd00fdc125c6b8b602fe959c029f0224ce0cdb910', + gas: 94617, + gasUsed: 62248, + gasPrice: '8572767507', + effectiveGasPrice: '8572767507', + nonce: 1688, + cumulativeGasUsed: 6762162, + methodId: '0xa9059cbb', + value: '0', + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + from: '0x2990079bcdee240329a520d2444386fc119da21a', + isError: false, + valueTransfers: [ + { + from: '0x2990079bcdee240329a520d2444386fc119da21a', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: '120000', + decimal: 6, + contractAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USD Coin', + transferType: 'erc20', + }, + ], + logs: [], + transactionProtocol: 'ERC_20', + transactionCategory: 'TRANSFER', + transactionType: 'ERC_20_TRANSFER', + readable: 'Token: Transfer', + }, + { + hash: '0xbc67f71241b92b5c902d4fd0257e8d7137ebec0f608829bba964ce3dfdcf51b7', + timestamp: '2024-06-20T18:14:57.000Z', + chainId: 137, + accountId: 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 58399134, + blockHash: + '0x83eaf35919c5c8ba46779533b38589f0e33739320c52e6a588b615d87df50cbc', + gas: 11000000, + gasUsed: 5183636, + gasPrice: '31500000029', + effectiveGasPrice: '31500000029', + nonce: 6037, + cumulativeGasUsed: 7954869, + methodId: '0xfaf67b43', + value: '0', + to: '0xfe5d94a2b1b3066c814ee5fd024f8c3f99ef51f4', + from: '0x4c4404d760b18f824735f4ad746f7d809baf3432', + isError: false, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '1', + contractAddress: '0xfe5d94a2b1b3066c814ee5fd024f8c3f99ef51f4', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0xa73254e3cc0c03c2af521b09ed848ec1f37c6e6f0b9ca92781c39f52fa3796cc', + timestamp: '2024-06-20T18:14:45.000Z', + chainId: 137, + accountId: 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 58399128, + blockHash: + '0x823df9d476c3e9963b6ae37be3392ba88d5c95edaaeeed07136c290b0fb2056e', + gas: 53812, + gasUsed: 48920, + gasPrice: '30000000028', + effectiveGasPrice: '30000000028', + nonce: 1531, + cumulativeGasUsed: 10254933, + methodId: '0x9c96eec5', + value: '0', + to: '0x870b5c4e16adf0ac629cd6053438a71908fc6132', + from: '0x3d6f605526f69d0376c6f4aceee5f2a47e255f89', + isError: false, + valueTransfers: [ + { + from: '0x9d1b1669c73b033dfe47ae5a0164ab96df25b944', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: '999098777100000000000', + decimal: 18, + contractAddress: '0x870b5c4e16adf0ac629cd6053438a71908fc6132', + symbol: 'Invitation Link : https://zero-bridge.xyz/', + name: 'LayerZero : Bridge Rewards', + transferType: 'erc20', + }, + ], + logs: [], + transactionType: 'GENERIC_CONTRACT_CALL', + transactionCategory: 'CONTRACT_CALL', + readable: 'Unidentified Transaction', + }, + { + hash: '0xa87e7bc4ce49ff43541ea5d6e2af5a0281caacdd356418be1c1d85113acf7e74', + timestamp: '2024-06-20T18:14:35.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 20134658, + blockHash: + '0xc5aeba1b9a31ddb7f39d071a8d2b1d68eedbd3ab403e2cf8a351d24a20fbada8', + gas: 78298, + gasUsed: 51806, + gasPrice: '7453274534', + effectiveGasPrice: '7453274534', + nonce: 1687, + cumulativeGasUsed: 20241794, + methodId: '0xa9059cbb', + value: '0', + to: '0x6b175474e89094c44da98b954eedeac495271d0f', + from: '0x2990079bcdee240329a520d2444386fc119da21a', + isError: false, + valueTransfers: [ + { + from: '0x2990079bcdee240329a520d2444386fc119da21a', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: '100000000000000000', + decimal: 18, + contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + symbol: 'DAI', + name: 'Dai Stablecoin', + transferType: 'erc20', + }, + ], + logs: [], + transactionProtocol: 'ERC_20', + transactionCategory: 'TRANSFER', + transactionType: 'ERC_20_TRANSFER', + readable: 'Token: Transfer', + }, + { + hash: '0x4d6d83631f1742144e95397b5910bf4807192be38ad3a79d390cf4744c0c6550', + timestamp: '2024-06-19T16:28:23.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 20126979, + blockHash: + '0x4a8789c29ef4bc8ced1ff59735181d76b9548d8996e50b9175f5f84a6fc169c4', + gas: 71969, + gasUsed: 59816, + gasPrice: '13332526504', + effectiveGasPrice: '13332526504', + nonce: 1672, + cumulativeGasUsed: 13365022, + methodId: '0xf242432a', + value: '0', + to: '0xb66a603f4cfe17e3d27b87a8bfcad319856518b8', + from: '0x2990079bcdee240329a520d2444386fc119da21a', + isError: false, + valueTransfers: [ + { + from: '0x2990079bcdee240329a520d2444386fc119da21a', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: + '100059584189867566852894599154934721552567499366058460268305346163854916190212', + contractAddress: '0xb66a603f4cfe17e3d27b87a8bfcad319856518b8', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'GENERIC_CONTRACT_CALL', + transactionCategory: 'CONTRACT_CALL', + readable: 'Unidentified Transaction', + }, + { + hash: '0x064a773c959cfae4c5911fc730cb7920351e55f81e7aa661246b90c382f0054e', + timestamp: '2024-06-19T14:59:47.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 20126537, + blockHash: + '0x99b36ebd93ea840414cc09f6c441afc3cddada3ece4b7f95e28e66e2545d7d08', + gas: 98368, + gasUsed: 42056, + gasPrice: '7691903129', + effectiveGasPrice: '7691903129', + nonce: 1669, + cumulativeGasUsed: 24774395, + methodId: '0xf242432a', + value: '0', + to: '0xb66a603f4cfe17e3d27b87a8bfcad319856518b8', + from: '0x2990079bcdee240329a520d2444386fc119da21a', + isError: false, + valueTransfers: [ + { + from: '0x2990079bcdee240329a520d2444386fc119da21a', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: null, + tokenId: + '100059584189867566852894599154934721552567499366058460268305346163854916190212', + contractAddress: '0xb66a603f4cfe17e3d27b87a8bfcad319856518b8', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'GENERIC_CONTRACT_CALL', + transactionCategory: 'CONTRACT_CALL', + readable: 'Unidentified Transaction', + }, + { + hash: '0x833d5e703c269f691ae609805be789aac2a605662bb15099ce5361228ebed9e2', + timestamp: '2024-06-17T20:46:23.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 20113979, + blockHash: + '0x3ec9c170e8f059142f106daa5c54ebc2a0bb7c552d5e43ad8696d97bfee2de31', + gas: 98368, + gasUsed: 42056, + gasPrice: '7822363494', + effectiveGasPrice: '7822363494', + nonce: 1636, + cumulativeGasUsed: 10428073, + methodId: '0xf242432a', + value: '0', + to: '0xb66a603f4cfe17e3d27b87a8bfcad319856518b8', + from: '0x2990079bcdee240329a520d2444386fc119da21a', + isError: false, + valueTransfers: [ + { + from: '0x2990079bcdee240329a520d2444386fc119da21a', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: null, + tokenId: + '100059584189867566852894599154934721552567499366058460268305346163854916190212', + contractAddress: '0xb66a603f4cfe17e3d27b87a8bfcad319856518b8', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'GENERIC_CONTRACT_CALL', + transactionCategory: 'CONTRACT_CALL', + readable: 'Unidentified Transaction', + }, + { + hash: '0x1686ff4f3ca5aec7cdc13dbd786ef90741b09fe1150024a920fa72b8939238ae', + timestamp: '2024-06-17T20:35:47.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 20113926, + blockHash: + '0x380b18f09a627e24741e84cd47f00f57eea1ba67931e7ce010f7e85894af6d0b', + gas: 99802, + gasUsed: 61419, + gasPrice: '8368575474', + effectiveGasPrice: '8368575474', + nonce: 1632, + cumulativeGasUsed: 13180858, + methodId: '0x23b872dd', + value: '0', + to: '0x6cb26df0c825fece867a84658f87b0ecbcea72f6', + from: '0x2990079bcdee240329a520d2444386fc119da21a', + isError: false, + valueTransfers: [ + { + from: '0x2990079bcdee240329a520d2444386fc119da21a', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + tokenId: '2875', + contractAddress: '0x6cb26df0c825fece867a84658f87b0ecbcea72f6', + transferType: 'erc721', + }, + ], + logs: [], + transactionCategory: 'TRANSFER', + transactionProtocol: 'ERC_721', + transactionType: 'ERC_721_TRANSFER', + readable: 'Nft: Transfer', + }, + ], + unprocessedNetworks: [], + pageInfo: { hasNextPage: false, cursor: null, count: 34 }, +}; + +export const ACCOUNTS_API_ACTIVE_NETWORKS_RESPONSE = { + activeNetworks: [ + 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + 'eip155:8453:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + ], +}; diff --git a/e2e/api-mocking/mock-responses/feature-flags-mocks.ts b/e2e/api-mocking/mock-responses/feature-flags-mocks.ts new file mode 100644 index 00000000000..9cd9d334615 --- /dev/null +++ b/e2e/api-mocking/mock-responses/feature-flags-mocks.ts @@ -0,0 +1,164 @@ +export const SWAPS_FEATURE_FLAG_RESPONSE = { + ethereum: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: { + expectedDeadline: 45, + maxDeadline: 160, + returnTxHashAsap: false, + mobileActive: true, + extensionActive: true, + mobileActiveIos: true, + mobileActiveAndroid: true, + }, + }, + bsc: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: { + mobileActive: true, + extensionActive: true, + mobileActiveIos: true, + mobileActiveAndroid: true, + }, + }, + polygon: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: {}, + }, + avalanche: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: {}, + }, + arbitrum: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: { + mobileActive: true, + extensionActive: true, + mobileActiveIos: true, + mobileActiveAndroid: true, + }, + }, + optimism: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: {}, + }, + zksync: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: {}, + }, + linea: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: {}, + }, + base: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: { + mobileActive: true, + extensionActive: true, + mobileActiveIos: true, + mobileActiveAndroid: true, + }, + }, + sei: { + mobile_active: true, + extension_active: false, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: false, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: true } }, + smartTransactions: {}, + }, + smart_transactions: { mobile_active: true, extension_active: true }, + smartTransactions: { + mobileActive: true, + extensionActive: true, + mobileActiveIOS: false, + mobileActiveAndroid: false, + mobileReturnTxHashAsap: true, + extensionReturnTxHashAsap: true, + batchStatusPollingInterval: 5000, + }, + transactions: { + acceleratedPollingEnabled: false, + acceleratedPollingInterval: 5, + maxAcceleratedPolls: 0, + }, + swapRedesign: { mobileActive: false, extensionActive: true }, + migrateToV2: { extensionActive: false, mobileActive: false }, + compliance: { merkleScienceMinThreshold: 25000 }, + multiChainAssets: { pollingSeconds: 0 }, +}; diff --git a/e2e/api-mocking/mock-responses/staking-api-responses-mocks.ts b/e2e/api-mocking/mock-responses/staking-api-responses-mocks.ts new file mode 100644 index 00000000000..a73cf591402 --- /dev/null +++ b/e2e/api-mocking/mock-responses/staking-api-responses-mocks.ts @@ -0,0 +1,3463 @@ +export const POOLED_STAKING_VAULT_RESPONSE = [ + { + id: 2148691, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-08-05T00:00:00.000Z', + daily_apy: '2.160630689308144746', + created_at: '2025-08-06T01:00:01.209Z', + updated_at: '2025-08-06T01:00:01.209Z', + }, + { + id: 2137242, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-08-04T00:00:00.000Z', + daily_apy: '2.594766097760912223', + created_at: '2025-08-05T01:00:00.675Z', + updated_at: '2025-08-05T01:00:00.675Z', + }, + { + id: 2125229, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-08-03T00:00:00.000Z', + daily_apy: '2.230985359762322456', + created_at: '2025-08-04T01:00:03.147Z', + updated_at: '2025-08-04T01:00:03.147Z', + }, + { + id: 2113939, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-08-02T00:00:00.000Z', + daily_apy: '2.822078388979096626', + created_at: '2025-08-03T01:00:02.344Z', + updated_at: '2025-08-03T01:00:02.344Z', + }, + { + id: 2102453, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-08-01T00:00:00.000Z', + daily_apy: '2.354552962265530254', + created_at: '2025-08-02T01:00:00.479Z', + updated_at: '2025-08-02T01:00:00.479Z', + }, + { + id: 2090629, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-31T00:00:00.000Z', + daily_apy: '2.430527730769124502', + created_at: '2025-08-01T01:00:00.630Z', + updated_at: '2025-08-01T01:00:00.630Z', + }, + { + id: 2078463, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-30T00:00:00.000Z', + daily_apy: '2.360728942269321903', + created_at: '2025-07-31T01:00:00.432Z', + updated_at: '2025-07-31T01:00:00.432Z', + }, + { + id: 2067053, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-29T00:00:00.000Z', + daily_apy: '2.550855288009415321', + created_at: '2025-07-30T01:00:00.453Z', + updated_at: '2025-07-30T01:00:00.453Z', + }, + { + id: 2055302, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-28T00:00:00.000Z', + daily_apy: '2.699143982584143850', + created_at: '2025-07-29T01:00:00.728Z', + updated_at: '2025-07-29T01:00:00.728Z', + }, + { + id: 2044149, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-27T00:00:00.000Z', + daily_apy: '2.260023781340267047', + created_at: '2025-07-28T01:00:00.468Z', + updated_at: '2025-07-28T01:00:00.468Z', + }, + { + id: 2032916, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-26T00:00:00.000Z', + daily_apy: '2.367243569636514657', + created_at: '2025-07-27T01:00:00.570Z', + updated_at: '2025-07-27T01:00:00.570Z', + }, + { + id: 2019474, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-25T00:00:00.000Z', + daily_apy: '2.425507062525974170', + created_at: '2025-07-26T01:00:00.376Z', + updated_at: '2025-07-26T01:00:00.376Z', + }, + { + id: 2007822, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-24T00:00:00.000Z', + daily_apy: '2.416965602179156029', + created_at: '2025-07-25T01:00:00.220Z', + updated_at: '2025-07-25T01:00:00.220Z', + }, + { + id: 1996570, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-23T00:00:00.000Z', + daily_apy: '2.758675024861472788', + created_at: '2025-07-24T01:00:00.525Z', + updated_at: '2025-07-24T01:00:00.525Z', + }, + { + id: 1985426, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-22T00:00:00.000Z', + daily_apy: '2.666457904467836947', + created_at: '2025-07-23T01:00:00.300Z', + updated_at: '2025-07-23T01:00:00.300Z', + }, + { + id: 1971170, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-21T00:00:00.000Z', + daily_apy: '2.231467458835929591', + created_at: '2025-07-22T01:00:00.584Z', + updated_at: '2025-07-22T01:00:00.584Z', + }, + { + id: 1959984, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-20T00:00:00.000Z', + daily_apy: '2.301110730349541980', + created_at: '2025-07-21T01:00:00.883Z', + updated_at: '2025-07-21T01:00:00.883Z', + }, + { + id: 1948555, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-19T00:00:00.000Z', + daily_apy: '2.352087941423962002', + created_at: '2025-07-20T00:01:31.192Z', + updated_at: '2025-07-20T00:01:31.192Z', + }, + { + id: 1937514, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-18T00:00:00.000Z', + daily_apy: '2.709889646777819303', + created_at: '2025-07-19T01:00:00.307Z', + updated_at: '2025-07-19T01:00:00.307Z', + }, + { + id: 1926404, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-17T00:00:00.000Z', + daily_apy: '2.578081601755443584', + created_at: '2025-07-18T01:00:00.230Z', + updated_at: '2025-07-18T01:00:00.230Z', + }, + { + id: 1915046, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-16T00:00:00.000Z', + daily_apy: '2.511785450400075201', + created_at: '2025-07-17T00:01:31.166Z', + updated_at: '2025-07-17T00:01:31.166Z', + }, + { + id: 1904077, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-15T00:00:00.000Z', + daily_apy: '2.242968214074173364', + created_at: '2025-07-16T01:00:00.280Z', + updated_at: '2025-07-16T01:00:00.280Z', + }, + { + id: 1893132, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-14T00:00:00.000Z', + daily_apy: '2.572947490988122566', + created_at: '2025-07-15T01:00:00.622Z', + updated_at: '2025-07-15T01:00:00.622Z', + }, + { + id: 1881756, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-13T00:00:00.000Z', + daily_apy: '2.689218566706491482', + created_at: '2025-07-14T00:01:31.157Z', + updated_at: '2025-07-14T00:01:31.157Z', + }, + { + id: 1870771, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-12T00:00:00.000Z', + daily_apy: '2.365846320573506748', + created_at: '2025-07-13T01:00:00.303Z', + updated_at: '2025-07-13T01:00:00.303Z', + }, + { + id: 1859898, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-11T00:00:00.000Z', + daily_apy: '2.611392743830641121', + created_at: '2025-07-12T01:00:00.552Z', + updated_at: '2025-07-12T01:00:00.552Z', + }, + { + id: 1849051, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-10T00:00:00.000Z', + daily_apy: '2.474061921678370022', + created_at: '2025-07-11T01:00:00.318Z', + updated_at: '2025-07-11T01:00:00.318Z', + }, + { + id: 1837776, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-09T00:00:00.000Z', + daily_apy: '2.414119937632374226', + created_at: '2025-07-10T01:00:00.390Z', + updated_at: '2025-07-10T01:00:00.390Z', + }, + { + id: 1829760, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-08T00:00:00.000Z', + daily_apy: '3.010997754405619248', + created_at: '2025-07-09T07:00:00.507Z', + updated_at: '2025-07-09T07:00:00.507Z', + }, + { + id: 1819432, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-07T00:00:00.000Z', + daily_apy: '2.551856786885392865', + created_at: '2025-07-08T01:00:00.298Z', + updated_at: '2025-07-08T01:00:00.298Z', + }, + { + id: 1808679, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-06T00:00:00.000Z', + daily_apy: '2.322264721475653208', + created_at: '2025-07-07T01:00:00.482Z', + updated_at: '2025-07-07T01:00:00.482Z', + }, + { + id: 1797950, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-05T00:00:00.000Z', + daily_apy: '2.545299229175557965', + created_at: '2025-07-06T01:00:00.323Z', + updated_at: '2025-07-06T01:00:00.323Z', + }, + { + id: 1786799, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-04T00:00:00.000Z', + daily_apy: '2.238388442775579425', + created_at: '2025-07-05T01:00:00.445Z', + updated_at: '2025-07-05T01:00:00.445Z', + }, + { + id: 1776118, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-03T00:00:00.000Z', + daily_apy: '2.483210588259805088', + created_at: '2025-07-04T01:00:00.649Z', + updated_at: '2025-07-04T01:00:00.649Z', + }, + { + id: 1764050, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-02T00:00:00.000Z', + daily_apy: '2.361765502010076272', + created_at: '2025-07-03T00:44:17.079Z', + updated_at: '2025-07-03T00:44:17.079Z', + }, + { + id: 1752167, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-01T00:00:00.000Z', + daily_apy: '2.560299460784560951', + created_at: '2025-07-02T01:00:00.254Z', + updated_at: '2025-07-02T01:00:00.254Z', + }, + { + id: 1743842, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-30T00:00:00.000Z', + daily_apy: '3.092940485915711615', + created_at: '2025-07-01T07:51:52.275Z', + updated_at: '2025-07-01T07:51:52.275Z', + }, + { + id: 1729618, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-29T00:00:00.000Z', + daily_apy: '2.473858935145985675', + created_at: '2025-06-30T01:00:00.398Z', + updated_at: '2025-06-30T01:00:00.398Z', + }, + { + id: 1718983, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-28T00:00:00.000Z', + daily_apy: '2.636138892886318473', + created_at: '2025-06-29T01:00:00.277Z', + updated_at: '2025-06-29T01:00:00.277Z', + }, + { + id: 1708081, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-27T00:00:00.000Z', + daily_apy: '2.364594758530096958', + created_at: '2025-06-28T00:01:30.698Z', + updated_at: '2025-06-28T00:01:30.698Z', + }, + { + id: 1697568, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-26T00:00:00.000Z', + daily_apy: '2.599179838082240376', + created_at: '2025-06-27T01:00:00.401Z', + updated_at: '2025-06-27T01:00:00.401Z', + }, + { + id: 1687007, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-25T00:00:00.000Z', + daily_apy: '2.781241096874648894', + created_at: '2025-06-26T01:00:00.495Z', + updated_at: '2025-06-26T01:00:00.495Z', + }, + { + id: 1676614, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-24T00:00:00.000Z', + daily_apy: '3.313982129947740597', + created_at: '2025-06-25T01:00:00.300Z', + updated_at: '2025-06-25T01:00:00.300Z', + }, + { + id: 1665738, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-23T00:00:00.000Z', + daily_apy: '2.920984915416314934', + created_at: '2025-06-24T01:00:00.264Z', + updated_at: '2025-06-24T01:00:00.264Z', + }, + { + id: 1655321, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-22T00:00:00.000Z', + daily_apy: '2.508811228066068916', + created_at: '2025-06-23T01:00:00.510Z', + updated_at: '2025-06-23T01:00:00.510Z', + }, + { + id: 1644494, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-21T00:00:00.000Z', + daily_apy: '2.417145276593338606', + created_at: '2025-06-22T01:00:00.462Z', + updated_at: '2025-06-22T01:00:00.462Z', + }, + { + id: 1634125, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-20T00:00:00.000Z', + daily_apy: '3.132806762001707522', + created_at: '2025-06-21T01:00:00.369Z', + updated_at: '2025-06-21T01:00:00.369Z', + }, + { + id: 1623780, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-19T00:00:00.000Z', + daily_apy: '2.326934563020984126', + created_at: '2025-06-20T01:00:00.390Z', + updated_at: '2025-06-20T01:00:00.390Z', + }, + { + id: 1613458, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-18T00:00:00.000Z', + daily_apy: '2.528577333658723341', + created_at: '2025-06-19T01:00:01.798Z', + updated_at: '2025-06-19T01:00:01.798Z', + }, + { + id: 1603161, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-17T00:00:00.000Z', + daily_apy: '2.420846308287214689', + created_at: '2025-06-18T01:00:00.215Z', + updated_at: '2025-06-18T01:00:00.215Z', + }, + { + id: 1592902, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-16T00:00:00.000Z', + daily_apy: '2.317700676927453592', + created_at: '2025-06-17T01:00:01.107Z', + updated_at: '2025-06-17T01:00:01.107Z', + }, + { + id: 1582653, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-15T00:00:00.000Z', + daily_apy: '2.847038769765440542', + created_at: '2025-06-16T01:00:00.234Z', + updated_at: '2025-06-16T01:00:00.234Z', + }, + { + id: 1572428, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-14T00:00:00.000Z', + daily_apy: '2.661913891382594137', + created_at: '2025-06-15T01:00:00.311Z', + updated_at: '2025-06-15T01:00:00.311Z', + }, + { + id: 1562227, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-13T00:00:00.000Z', + daily_apy: '2.395936713018122124', + created_at: '2025-06-14T01:00:00.477Z', + updated_at: '2025-06-14T01:00:00.477Z', + }, + { + id: 1552056, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-12T00:00:00.000Z', + daily_apy: '2.308595718513410841', + created_at: '2025-06-13T01:00:00.357Z', + updated_at: '2025-06-13T01:00:00.357Z', + }, + { + id: 1541904, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-11T00:00:00.000Z', + daily_apy: '2.714989932921688108', + created_at: '2025-06-12T01:00:00.377Z', + updated_at: '2025-06-12T01:00:00.377Z', + }, + { + id: 1531775, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-10T00:00:00.000Z', + daily_apy: '2.730241025946509735', + created_at: '2025-06-11T01:00:00.451Z', + updated_at: '2025-06-11T01:00:00.451Z', + }, + { + id: 1504410, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-09T00:00:00.000Z', + daily_apy: '2.438940357185122676', + created_at: '2025-06-10T01:00:01.847Z', + updated_at: '2025-06-10T01:00:01.847Z', + }, + { + id: 1494384, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-08T00:00:00.000Z', + daily_apy: '2.394719753798906958', + created_at: '2025-06-09T01:00:01.247Z', + updated_at: '2025-06-09T01:00:01.247Z', + }, + { + id: 1484327, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-07T00:00:00.000Z', + daily_apy: '2.646813409672606029', + created_at: '2025-06-08T01:00:00.257Z', + updated_at: '2025-06-08T01:00:00.257Z', + }, + { + id: 1474294, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-06T00:00:00.000Z', + daily_apy: '3.610860546185630642', + created_at: '2025-06-07T01:00:00.227Z', + updated_at: '2025-06-07T01:00:00.227Z', + }, + { + id: 1464233, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-05T00:00:00.000Z', + daily_apy: '2.866066000911694912', + created_at: '2025-06-06T01:00:00.204Z', + updated_at: '2025-06-06T01:00:00.204Z', + }, + { + id: 1454249, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-04T00:00:00.000Z', + daily_apy: '2.548511908606253429', + created_at: '2025-06-05T01:00:00.236Z', + updated_at: '2025-06-05T01:00:00.236Z', + }, + { + id: 1444704, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-03T00:00:00.000Z', + daily_apy: '2.765209089148432467', + created_at: '2025-06-04T01:00:00.229Z', + updated_at: '2025-06-04T01:00:00.229Z', + }, + { + id: 1434767, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-02T00:00:00.000Z', + daily_apy: '2.718609123877091482', + created_at: '2025-06-03T01:00:00.287Z', + updated_at: '2025-06-03T01:00:00.287Z', + }, + { + id: 1424854, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-01T00:00:00.000Z', + daily_apy: '2.674633072827114657', + created_at: '2025-06-02T01:00:00.242Z', + updated_at: '2025-06-02T01:00:00.242Z', + }, + { + id: 1414965, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-31T00:00:00.000Z', + daily_apy: '2.503740742292486449', + created_at: '2025-06-01T01:00:00.252Z', + updated_at: '2025-06-01T01:00:00.252Z', + }, + { + id: 1405100, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-30T00:00:00.000Z', + daily_apy: '2.377321111112683739', + created_at: '2025-05-31T01:00:00.247Z', + updated_at: '2025-05-31T01:00:00.247Z', + }, + { + id: 1394849, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-29T00:00:00.000Z', + daily_apy: '2.266832495716882467', + created_at: '2025-05-30T00:01:30.569Z', + updated_at: '2025-05-30T00:01:30.569Z', + }, + { + id: 1385032, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-28T00:00:00.000Z', + daily_apy: '2.761684766061966040', + created_at: '2025-05-29T01:00:01.647Z', + updated_at: '2025-05-29T01:00:01.647Z', + }, + { + id: 1375197, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-27T00:00:00.000Z', + daily_apy: '2.254981325304014602', + created_at: '2025-05-28T01:00:00.227Z', + updated_at: '2025-05-28T01:00:00.227Z', + }, + { + id: 1365429, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-26T00:00:00.000Z', + daily_apy: '2.455358672817514712', + created_at: '2025-05-27T01:00:00.247Z', + updated_at: '2025-05-27T01:00:00.247Z', + }, + { + id: 1355725, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-25T00:00:00.000Z', + daily_apy: '2.171054355556651327', + created_at: '2025-05-26T01:00:00.204Z', + updated_at: '2025-05-26T01:00:00.204Z', + }, + { + id: 1346004, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-24T00:00:00.000Z', + daily_apy: '1.923545158765472788', + created_at: '2025-05-25T01:00:00.223Z', + updated_at: '2025-05-25T01:00:00.223Z', + }, + { + id: 1336268, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-23T00:00:00.000Z', + daily_apy: '2.616579553526408131', + created_at: '2025-05-24T01:00:00.242Z', + updated_at: '2025-05-24T01:00:00.242Z', + }, + { + id: 1326634, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-22T00:00:00.000Z', + daily_apy: '2.208794804288317035', + created_at: '2025-05-23T01:00:00.363Z', + updated_at: '2025-05-23T01:00:00.363Z', + }, + { + id: 1316985, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-21T00:00:00.000Z', + daily_apy: '1.975900247716129148', + created_at: '2025-05-22T01:00:00.244Z', + updated_at: '2025-05-22T01:00:00.244Z', + }, + { + id: 1307360, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-20T00:00:00.000Z', + daily_apy: '1.823334231538022954', + created_at: '2025-05-21T01:00:00.224Z', + updated_at: '2025-05-21T01:00:00.224Z', + }, + { + id: 1297969, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-19T00:00:00.000Z', + daily_apy: '1.931945364844438938', + created_at: '2025-05-20T01:00:00.198Z', + updated_at: '2025-05-20T01:00:00.198Z', + }, + { + id: 1288028, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-18T00:00:00.000Z', + daily_apy: '1.946455943490720299', + created_at: '2025-05-19T01:00:00.281Z', + updated_at: '2025-05-19T01:00:00.281Z', + }, + { + id: 1278475, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-17T00:00:00.000Z', + daily_apy: '1.857306102146380254', + created_at: '2025-05-18T01:00:00.228Z', + updated_at: '2025-05-18T01:00:00.228Z', + }, + { + id: 1268946, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-16T00:00:00.000Z', + daily_apy: '2.636579843247607965', + created_at: '2025-05-17T01:00:00.196Z', + updated_at: '2025-05-17T01:00:00.196Z', + }, + { + id: 1259479, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-15T00:00:00.000Z', + daily_apy: '2.730951603838317644', + created_at: '2025-05-16T01:00:00.427Z', + updated_at: '2025-05-16T01:00:00.427Z', + }, + { + id: 1250719, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-14T00:00:00.000Z', + daily_apy: '2.892905192811717752', + created_at: '2025-05-15T01:00:00.216Z', + updated_at: '2025-05-15T01:00:00.216Z', + }, + { + id: 1241959, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-13T00:00:00.000Z', + daily_apy: '2.963932043397683281', + created_at: '2025-05-14T01:00:00.239Z', + updated_at: '2025-05-14T01:00:00.239Z', + }, + { + id: 1233199, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-12T00:00:00.000Z', + daily_apy: '2.888689132021701945', + created_at: '2025-05-13T01:00:00.409Z', + updated_at: '2025-05-13T01:00:00.409Z', + }, + { + id: 1224439, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-11T00:00:00.000Z', + daily_apy: '2.734279654635252893', + created_at: '2025-05-12T01:00:00.255Z', + updated_at: '2025-05-12T01:00:00.255Z', + }, + { + id: 1215679, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-10T00:00:00.000Z', + daily_apy: '2.319366804802240503', + created_at: '2025-05-11T01:00:00.371Z', + updated_at: '2025-05-11T01:00:00.371Z', + }, + { + id: 1206919, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-09T00:00:00.000Z', + daily_apy: '2.919990925078954060', + created_at: '2025-05-10T01:00:00.494Z', + updated_at: '2025-05-10T01:00:00.494Z', + }, + { + id: 1198159, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-08T00:00:00.000Z', + daily_apy: '2.854227652040402271', + created_at: '2025-05-09T01:00:00.241Z', + updated_at: '2025-05-09T01:00:00.241Z', + }, + { + id: 1188669, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-07T00:00:00.000Z', + daily_apy: '2.655421738777143586', + created_at: '2025-05-08T00:35:03.897Z', + updated_at: '2025-05-08T00:35:03.897Z', + }, + { + id: 1179544, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-06T00:00:00.000Z', + daily_apy: '2.295015278636215653', + created_at: '2025-05-07T01:00:00.282Z', + updated_at: '2025-05-07T01:00:00.282Z', + }, + { + id: 1171149, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-05T00:00:00.000Z', + daily_apy: '2.463374048331001162', + created_at: '2025-05-06T01:00:00.221Z', + updated_at: '2025-05-06T01:00:00.221Z', + }, + { + id: 1162389, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-04T00:00:00.000Z', + daily_apy: '2.521618298931638330', + created_at: '2025-05-05T01:00:00.287Z', + updated_at: '2025-05-05T01:00:00.287Z', + }, + { + id: 1153629, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-03T00:00:00.000Z', + daily_apy: '2.595101808726805420', + created_at: '2025-05-04T01:00:00.371Z', + updated_at: '2025-05-04T01:00:00.371Z', + }, + { + id: 1144869, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-02T00:00:00.000Z', + daily_apy: '2.787938980201913496', + created_at: '2025-05-03T01:09:28.224Z', + updated_at: '2025-05-03T01:09:28.224Z', + }, + { + id: 1136474, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-01T00:00:00.000Z', + daily_apy: '2.431794215797376217', + created_at: '2025-05-02T01:00:00.264Z', + updated_at: '2025-05-02T01:00:00.264Z', + }, + { + id: 1127714, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-30T00:00:00.000Z', + daily_apy: '2.552364609574939420', + created_at: '2025-05-01T01:00:00.212Z', + updated_at: '2025-05-01T01:00:00.212Z', + }, + { + id: 1118954, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-29T00:00:00.000Z', + daily_apy: '2.597304459121422799', + created_at: '2025-04-30T01:00:00.402Z', + updated_at: '2025-04-30T01:00:00.402Z', + }, + { + id: 1109829, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-28T00:00:00.000Z', + daily_apy: '2.639328108417040417', + created_at: '2025-04-29T01:00:00.276Z', + updated_at: '2025-04-29T01:00:00.276Z', + }, + { + id: 1100704, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-27T00:00:00.000Z', + daily_apy: '2.731128574803426936', + created_at: '2025-04-28T00:54:02.772Z', + updated_at: '2025-04-28T00:54:02.772Z', + }, + { + id: 1091944, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-26T00:00:00.000Z', + daily_apy: '2.513730750183385288', + created_at: '2025-04-27T01:00:00.388Z', + updated_at: '2025-04-27T01:00:00.388Z', + }, + { + id: 1083184, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-25T00:00:00.000Z', + daily_apy: '3.657783187026349447', + created_at: '2025-04-26T01:00:00.476Z', + updated_at: '2025-04-26T01:00:00.476Z', + }, + { + id: 1074424, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-24T00:00:00.000Z', + daily_apy: '2.497561674999690265', + created_at: '2025-04-25T01:00:00.425Z', + updated_at: '2025-04-25T01:00:00.425Z', + }, + { + id: 1065664, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-23T00:00:00.000Z', + daily_apy: '2.434718736272556139', + created_at: '2025-04-24T00:41:56.479Z', + updated_at: '2025-04-24T00:41:56.479Z', + }, + { + id: 1056904, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-22T00:00:00.000Z', + daily_apy: '3.089233192365398230', + created_at: '2025-04-23T01:00:00.592Z', + updated_at: '2025-04-23T01:00:00.592Z', + }, + { + id: 1048168, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-21T00:00:00.000Z', + daily_apy: '3.073296504073085343', + created_at: '2025-04-22T01:00:00.247Z', + updated_at: '2025-04-22T01:00:00.247Z', + }, + { + id: 1039093, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-20T00:00:00.000Z', + daily_apy: '2.351103366533415929', + created_at: '2025-04-21T00:37:31.958Z', + updated_at: '2025-04-21T00:37:31.958Z', + }, + { + id: 1030405, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-19T00:00:00.000Z', + daily_apy: '2.225352639174754480', + created_at: '2025-04-20T01:00:00.251Z', + updated_at: '2025-04-20T01:00:00.251Z', + }, + { + id: 1021741, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-18T00:00:00.000Z', + daily_apy: '2.644886616949982688', + created_at: '2025-04-19T01:00:00.357Z', + updated_at: '2025-04-19T01:00:00.357Z', + }, + { + id: 1012741, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-17T00:00:00.000Z', + daily_apy: '3.042329145189525000', + created_at: '2025-04-18T00:57:21.407Z', + updated_at: '2025-04-18T00:57:21.407Z', + }, + { + id: 1004125, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-16T00:00:00.000Z', + daily_apy: '3.643247075892654388', + created_at: '2025-04-17T01:00:00.386Z', + updated_at: '2025-04-17T01:00:00.386Z', + }, + { + id: 995533, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-15T00:00:00.000Z', + daily_apy: '2.520282215494650327', + created_at: '2025-04-16T01:00:00.359Z', + updated_at: '2025-04-16T01:00:00.359Z', + }, + { + id: 986965, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-14T00:00:00.000Z', + daily_apy: '3.208098989378294303', + created_at: '2025-04-15T01:00:00.244Z', + updated_at: '2025-04-15T01:00:00.244Z', + }, + { + id: 978421, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-13T00:00:00.000Z', + daily_apy: '2.721165617974596073', + created_at: '2025-04-14T01:24:47.357Z', + updated_at: '2025-04-14T01:24:47.357Z', + }, + { + id: 969901, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-12T00:00:00.000Z', + daily_apy: '2.250107844448287445', + created_at: '2025-04-13T01:00:00.586Z', + updated_at: '2025-04-13T01:00:00.586Z', + }, + { + id: 961405, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-11T00:00:00.000Z', + daily_apy: '2.344017241063298728', + created_at: '2025-04-12T01:00:00.643Z', + updated_at: '2025-04-12T01:00:00.643Z', + }, + { + id: 952933, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-10T00:00:00.000Z', + daily_apy: '2.569225132702528208', + created_at: '2025-04-11T01:00:00.279Z', + updated_at: '2025-04-11T01:00:00.279Z', + }, + { + id: 944485, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-09T00:00:00.000Z', + daily_apy: '3.234066352579156858', + created_at: '2025-04-10T01:00:00.450Z', + updated_at: '2025-04-10T01:00:00.450Z', + }, + { + id: 936412, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-08T00:00:00.000Z', + daily_apy: '2.404572998161749392', + created_at: '2025-04-09T01:00:00.472Z', + updated_at: '2025-04-09T01:00:00.472Z', + }, + { + id: 930462, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-07T00:00:00.000Z', + daily_apy: '16.274294029981817920', + created_at: '2025-04-08T08:00:00.412Z', + updated_at: '2025-04-08T08:00:00.412Z', + }, + { + id: 922784, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-06T00:00:00.000Z', + daily_apy: '2.327823284439173894', + created_at: '2025-04-07T01:00:00.387Z', + updated_at: '2025-04-07T01:00:00.387Z', + }, + { + id: 914432, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-05T00:00:00.000Z', + daily_apy: '2.713790128923583407', + created_at: '2025-04-06T01:00:00.337Z', + updated_at: '2025-04-06T01:00:00.337Z', + }, + { + id: 906104, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-04T00:00:00.000Z', + daily_apy: '3.137879600264120575', + created_at: '2025-04-05T01:40:12.734Z', + updated_at: '2025-04-05T01:40:12.734Z', + }, + { + id: 897800, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-03T00:00:00.000Z', + daily_apy: '2.749937989528119524', + created_at: '2025-04-04T01:00:00.477Z', + updated_at: '2025-04-04T01:00:00.477Z', + }, + { + id: 889520, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-02T00:00:00.000Z', + daily_apy: '2.808586782003876191', + created_at: '2025-04-03T01:00:00.288Z', + updated_at: '2025-04-03T01:00:00.288Z', + }, + { + id: 881264, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-01T00:00:00.000Z', + daily_apy: '2.641420673236496893', + created_at: '2025-04-02T01:00:00.522Z', + updated_at: '2025-04-02T01:00:00.522Z', + }, + { + id: 873032, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-31T00:00:00.000Z', + daily_apy: '2.782427831689054822', + created_at: '2025-04-01T01:00:00.645Z', + updated_at: '2025-04-01T01:00:00.645Z', + }, + { + id: 864140, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-30T00:00:00.000Z', + daily_apy: '2.435077125451363938', + created_at: '2025-03-31T01:00:00.403Z', + updated_at: '2025-03-31T01:00:00.403Z', + }, + { + id: 855956, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-29T00:00:00.000Z', + daily_apy: '2.862289970264342588', + created_at: '2025-03-30T01:00:00.743Z', + updated_at: '2025-03-30T01:00:00.743Z', + }, + { + id: 847796, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-28T00:00:00.000Z', + daily_apy: '2.777681416203091593', + created_at: '2025-03-29T01:00:00.472Z', + updated_at: '2025-03-29T01:00:00.472Z', + }, + { + id: 835532, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-27T00:00:00.000Z', + daily_apy: '2.407050370521265321', + created_at: '2025-03-28T01:00:00.320Z', + updated_at: '2025-03-28T01:00:00.320Z', + }, + { + id: 820184, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-26T00:00:00.000Z', + daily_apy: '2.334231120347892478', + created_at: '2025-03-27T01:00:00.594Z', + updated_at: '2025-03-27T01:00:00.594Z', + }, + { + id: 803520, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-25T00:00:00.000Z', + daily_apy: '2.288783268450069690', + created_at: '2025-03-26T01:00:00.327Z', + updated_at: '2025-03-26T01:00:00.327Z', + }, + { + id: 787248, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-24T00:00:00.000Z', + daily_apy: '3.207491419307942588', + created_at: '2025-03-25T01:00:00.352Z', + updated_at: '2025-03-25T01:00:00.352Z', + }, + { + id: 771365, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-23T00:00:00.000Z', + daily_apy: '2.427588569162684071', + created_at: '2025-03-24T01:00:00.532Z', + updated_at: '2025-03-24T01:00:00.532Z', + }, + { + id: 754825, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-22T00:00:00.000Z', + daily_apy: '2.427961598326381858', + created_at: '2025-03-23T01:00:00.458Z', + updated_at: '2025-03-23T01:00:00.458Z', + }, + { + id: 738673, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-21T00:00:00.000Z', + daily_apy: '2.221222106732271571', + created_at: '2025-03-22T01:00:00.581Z', + updated_at: '2025-03-22T01:00:00.581Z', + }, + { + id: 722908, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-20T00:00:00.000Z', + daily_apy: '3.109400223632458407', + created_at: '2025-03-21T01:00:00.403Z', + updated_at: '2025-03-21T01:00:00.403Z', + }, + { + id: 706182, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-19T00:00:00.000Z', + daily_apy: '2.114127349815966869', + created_at: '2025-03-20T01:00:00.412Z', + updated_at: '2025-03-20T01:00:00.412Z', + }, + { + id: 690174, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-18T00:00:00.000Z', + daily_apy: '1.995508052653456692', + created_at: '2025-03-19T01:00:00.254Z', + updated_at: '2025-03-19T01:00:00.254Z', + }, + { + id: 674213, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-17T00:00:00.000Z', + daily_apy: '2.808134980278030199', + created_at: '2025-03-18T01:00:00.236Z', + updated_at: '2025-03-18T01:00:00.236Z', + }, + { + id: 658682, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-16T00:00:00.000Z', + daily_apy: '2.046101340514063938', + created_at: '2025-03-17T01:00:00.504Z', + updated_at: '2025-03-17T01:00:00.504Z', + }, + { + id: 642842, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-15T00:00:00.000Z', + daily_apy: '2.412183366907788883', + created_at: '2025-03-16T01:00:00.448Z', + updated_at: '2025-03-16T01:00:00.448Z', + }, + { + id: 627026, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-14T00:00:00.000Z', + daily_apy: '2.416168547971019726', + created_at: '2025-03-15T01:00:01.011Z', + updated_at: '2025-03-15T01:00:01.011Z', + }, + { + id: 610915, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-13T00:00:00.000Z', + daily_apy: '3.320387982775072235', + created_at: '2025-03-14T01:00:00.516Z', + updated_at: '2025-03-14T01:00:00.516Z', + }, + { + id: 595171, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-12T00:00:00.000Z', + daily_apy: '2.398300210461381471', + created_at: '2025-03-13T01:00:01.132Z', + updated_at: '2025-03-13T01:00:01.132Z', + }, + { + id: 579789, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-11T00:00:00.000Z', + daily_apy: '2.509179581886257688', + created_at: '2025-03-12T01:00:00.790Z', + updated_at: '2025-03-12T01:00:00.790Z', + }, + { + id: 563878, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-10T00:00:00.000Z', + daily_apy: '3.040062426245623838', + created_at: '2025-03-11T01:00:00.498Z', + updated_at: '2025-03-11T01:00:00.498Z', + }, + { + id: 548653, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-09T00:00:00.000Z', + daily_apy: '3.129336844068671681', + created_at: '2025-03-10T01:00:00.818Z', + updated_at: '2025-03-10T01:00:00.818Z', + }, + { + id: 532798, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-08T00:00:00.000Z', + daily_apy: '2.863111335359804701', + created_at: '2025-03-09T01:00:00.390Z', + updated_at: '2025-03-09T01:00:00.390Z', + }, + { + id: 517621, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-07T00:00:00.000Z', + daily_apy: '2.264822137341302157', + created_at: '2025-03-08T01:00:00.482Z', + updated_at: '2025-03-08T01:00:00.482Z', + }, + { + id: 501814, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-06T00:00:00.000Z', + daily_apy: '2.532227088223784071', + created_at: '2025-03-07T01:00:00.255Z', + updated_at: '2025-03-07T01:00:00.255Z', + }, + { + id: 486358, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-05T00:00:00.000Z', + daily_apy: '2.732211047232251383', + created_at: '2025-03-06T01:00:00.523Z', + updated_at: '2025-03-06T01:00:00.523Z', + }, + { + id: 470926, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-04T00:00:00.000Z', + daily_apy: '2.512702783538479314', + created_at: '2025-03-05T01:00:00.556Z', + updated_at: '2025-03-05T01:00:00.556Z', + }, + { + id: 455873, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-03T00:00:00.000Z', + daily_apy: '2.763614942213400498', + created_at: '2025-03-04T01:00:00.316Z', + updated_at: '2025-03-04T01:00:00.316Z', + }, + { + id: 440322, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-02T00:00:00.000Z', + daily_apy: '3.428494642246296405', + created_at: '2025-03-03T01:00:00.700Z', + updated_at: '2025-03-03T01:00:00.700Z', + }, + { + id: 425246, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-01T00:00:00.000Z', + daily_apy: '2.154023267416044690', + created_at: '2025-03-02T01:00:00.870Z', + updated_at: '2025-03-02T01:00:00.870Z', + }, + { + id: 409659, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-28T00:00:00.000Z', + daily_apy: '2.598153256102263053', + created_at: '2025-03-01T01:00:00.566Z', + updated_at: '2025-03-01T01:00:00.566Z', + }, + { + id: 394443, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-27T00:00:00.000Z', + daily_apy: '3.762723089359417810', + created_at: '2025-02-28T01:00:00.524Z', + updated_at: '2025-02-28T01:00:00.524Z', + }, + { + id: 379274, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-26T00:00:00.000Z', + daily_apy: '2.229242686432259735', + created_at: '2025-02-27T01:00:00.610Z', + updated_at: '2025-02-27T01:00:00.610Z', + }, + { + id: 364311, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-25T00:00:00.000Z', + daily_apy: '3.084851610358903374', + created_at: '2025-02-26T01:00:00.611Z', + updated_at: '2025-02-26T01:00:00.611Z', + }, + { + id: 348804, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-24T00:00:00.000Z', + daily_apy: '2.178221453819123396', + created_at: '2025-02-25T01:00:00.695Z', + updated_at: '2025-02-25T01:00:00.695Z', + }, + { + id: 333461, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-23T00:00:00.000Z', + daily_apy: '2.279118367895585177', + created_at: '2025-02-24T01:00:00.520Z', + updated_at: '2025-02-24T01:00:00.520Z', + }, + { + id: 318485, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-22T00:00:00.000Z', + daily_apy: '2.162399050384939712', + created_at: '2025-02-23T01:00:00.438Z', + updated_at: '2025-02-23T01:00:00.438Z', + }, + { + id: 303874, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-21T00:00:00.000Z', + daily_apy: '2.456315229864533296', + created_at: '2025-02-22T01:00:01.098Z', + updated_at: '2025-02-22T01:00:01.098Z', + }, + { + id: 288678, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-20T00:00:00.000Z', + daily_apy: '3.114867534342414989', + created_at: '2025-02-21T01:00:00.390Z', + updated_at: '2025-02-21T01:00:00.390Z', + }, + { + id: 274160, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-19T00:00:00.000Z', + daily_apy: '2.273150114369428540', + created_at: '2025-02-20T01:00:00.686Z', + updated_at: '2025-02-20T01:00:00.686Z', + }, + { + id: 259375, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-18T00:00:00.000Z', + daily_apy: '2.601753752988867146', + created_at: '2025-02-19T01:00:00.460Z', + updated_at: '2025-02-19T01:00:00.460Z', + }, + { + id: 244325, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-17T00:00:00.000Z', + daily_apy: '2.371788704658418308', + created_at: '2025-02-18T01:00:00.579Z', + updated_at: '2025-02-18T01:00:00.579Z', + }, + { + id: 229949, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-16T00:00:00.000Z', + daily_apy: '2.037130166329167644', + created_at: '2025-02-17T01:00:00.368Z', + updated_at: '2025-02-17T01:00:00.368Z', + }, + { + id: 214997, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-15T00:00:00.000Z', + daily_apy: '2.495509141072538330', + created_at: '2025-02-16T01:00:00.737Z', + updated_at: '2025-02-16T01:00:00.737Z', + }, + { + id: 200715, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-14T00:00:00.000Z', + daily_apy: '2.760147959320520741', + created_at: '2025-02-15T01:00:00.521Z', + updated_at: '2025-02-15T01:00:00.521Z', + }, + { + id: 185862, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-13T00:00:00.000Z', + daily_apy: '2.620957696005122124', + created_at: '2025-02-14T01:00:00.438Z', + updated_at: '2025-02-14T01:00:00.438Z', + }, + { + id: 171673, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-12T00:00:00.000Z', + daily_apy: '3.520427702298995520', + created_at: '2025-02-13T01:00:00.384Z', + updated_at: '2025-02-13T01:00:00.384Z', + }, + { + id: 156917, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-11T00:00:00.000Z', + daily_apy: '2.314556111097796460', + created_at: '2025-02-12T01:00:00.293Z', + updated_at: '2025-02-12T01:00:00.293Z', + }, + { + id: 142517, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-10T00:00:00.000Z', + daily_apy: '2.171292704662784237', + created_at: '2025-02-11T01:00:00.521Z', + updated_at: '2025-02-11T01:00:00.521Z', + }, + { + id: 128165, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-09T00:00:00.000Z', + daily_apy: '2.676286927153115044', + created_at: '2025-02-10T01:00:00.790Z', + updated_at: '2025-02-10T01:00:00.790Z', + }, + { + id: 113861, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-08T00:00:00.000Z', + daily_apy: '2.386368022241520299', + created_at: '2025-02-09T01:00:00.401Z', + updated_at: '2025-02-09T01:00:00.401Z', + }, + { + id: 99624, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-07T00:00:00.000Z', + daily_apy: '2.422358786455122843', + created_at: '2025-02-08T01:00:00.409Z', + updated_at: '2025-02-08T01:00:00.409Z', + }, + { + id: 85398, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-06T00:00:00.000Z', + daily_apy: '2.265223347079687334', + created_at: '2025-02-07T01:00:00.464Z', + updated_at: '2025-02-07T01:00:00.464Z', + }, + { + id: 71538, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-05T00:00:00.000Z', + daily_apy: '2.718738720546652931', + created_at: '2025-02-06T01:00:00.630Z', + updated_at: '2025-02-06T01:00:00.630Z', + }, + { + id: 57125, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-04T00:00:00.000Z', + daily_apy: '2.305794112369113883', + created_at: '2025-02-05T01:00:00.369Z', + updated_at: '2025-02-05T01:00:00.369Z', + }, + { + id: 43061, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-03T00:00:00.000Z', + daily_apy: '2.861341474969266577', + created_at: '2025-02-04T01:00:00.488Z', + updated_at: '2025-02-04T01:00:00.488Z', + }, + { + id: 29343, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-02T00:00:00.000Z', + daily_apy: '2.214925435010173341', + created_at: '2025-02-03T01:00:00.681Z', + updated_at: '2025-02-03T01:00:00.681Z', + }, + { + id: 15374, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-01T00:00:00.000Z', + daily_apy: '2.490612483336790321', + created_at: '2025-02-02T01:00:00.369Z', + updated_at: '2025-02-02T01:00:00.369Z', + }, + { + id: 1453, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-31T00:00:00.000Z', + daily_apy: '2.384122251339697898', + created_at: '2025-02-01T01:00:00.892Z', + updated_at: '2025-02-01T01:00:00.892Z', + }, + { + id: 1, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-30T00:00:00.000Z', + daily_apy: '2.588418773657355365', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 2, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-29T00:00:00.000Z', + daily_apy: '2.369512244109374004', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 3, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-28T00:00:00.000Z', + daily_apy: '2.562467575560847069', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 4, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-27T00:00:00.000Z', + daily_apy: '2.911412020118756416', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 5, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-26T00:00:00.000Z', + daily_apy: '2.438629958970523673', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 6, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-25T00:00:00.000Z', + daily_apy: '2.153496352059892035', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 7, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-24T00:00:00.000Z', + daily_apy: '2.114212655218078374', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 8, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-23T00:00:00.000Z', + daily_apy: '2.003296807378464491', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 9, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-22T00:00:00.000Z', + daily_apy: '2.687482649277195133', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 10, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-21T00:00:00.000Z', + daily_apy: '2.861342120295913440', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 11, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-20T00:00:00.000Z', + daily_apy: '3.774434617124937777', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 12, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-19T00:00:00.000Z', + daily_apy: '2.663583581696357190', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 13, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-18T00:00:00.000Z', + daily_apy: '2.502571414414859403', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 14, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-17T00:00:00.000Z', + daily_apy: '2.515897726411376051', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 15, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-16T00:00:00.000Z', + daily_apy: '3.200407596144865155', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 16, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-15T00:00:00.000Z', + daily_apy: '3.120664973229844248', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 17, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-14T00:00:00.000Z', + daily_apy: '3.077150313762752157', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 18, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-13T00:00:00.000Z', + daily_apy: '2.707778270294490265', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 19, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-12T00:00:00.000Z', + daily_apy: '2.433065699826669027', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 20, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-11T00:00:00.000Z', + daily_apy: '2.527220371813221903', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 21, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-10T00:00:00.000Z', + daily_apy: '2.519988313771537168', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 22, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-09T00:00:00.000Z', + daily_apy: '2.398646948217626051', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 23, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-08T00:00:00.000Z', + daily_apy: '2.733806455540747566', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 24, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-07T00:00:00.000Z', + daily_apy: '2.328383448288102046', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 25, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-06T00:00:00.000Z', + daily_apy: '2.308889054919307301', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 26, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-05T00:00:00.000Z', + daily_apy: '2.667287457568427655', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 27, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-04T00:00:00.000Z', + daily_apy: '2.769141076722838274', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 28, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-03T00:00:00.000Z', + daily_apy: '2.172711392137953816', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 29, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-02T00:00:00.000Z', + daily_apy: '2.380809034996447898', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 30, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-01T00:00:00.000Z', + daily_apy: '2.316257371201709071', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 31, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-31T00:00:00.000Z', + daily_apy: '2.381666501520736228', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 32, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-30T00:00:00.000Z', + daily_apy: '3.290020080906444690', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 33, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-29T00:00:00.000Z', + daily_apy: '2.615126145462823341', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 34, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-28T00:00:00.000Z', + daily_apy: '2.557189631387469027', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 35, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-27T00:00:00.000Z', + daily_apy: '2.158458164262885066', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 36, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-26T00:00:00.000Z', + daily_apy: '2.206510584899516980', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 37, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-25T00:00:00.000Z', + daily_apy: '2.158614798202507662', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 38, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-24T00:00:00.000Z', + daily_apy: '2.295841281173372788', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 39, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-23T00:00:00.000Z', + daily_apy: '2.537213663964201538', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 40, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-22T00:00:00.000Z', + daily_apy: '2.526363960719146691', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 41, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-21T00:00:00.000Z', + daily_apy: '2.827957235815345022', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 42, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-20T00:00:00.000Z', + daily_apy: '1.923858579064909181', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 43, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-19T00:00:00.000Z', + daily_apy: '2.312101511338184403', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 44, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-18T00:00:00.000Z', + daily_apy: '2.379774509103764028', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 45, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-17T00:00:00.000Z', + daily_apy: '2.139060810693860797', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 46, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-16T00:00:00.000Z', + daily_apy: '2.961584998909087010', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 47, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-15T00:00:00.000Z', + daily_apy: '2.379826551660605144', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 48, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-14T00:00:00.000Z', + daily_apy: '2.510051909115494469', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 49, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-13T00:00:00.000Z', + daily_apy: '2.306349129890238053', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 50, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-12T00:00:00.000Z', + daily_apy: '2.769878005945986504', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 51, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-11T00:00:00.000Z', + daily_apy: '2.114872475542218916', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 52, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-10T00:00:00.000Z', + daily_apy: '2.155594623401686062', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 53, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-09T00:00:00.000Z', + daily_apy: '2.710501371113315376', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 54, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-08T00:00:00.000Z', + daily_apy: '2.399345980776163772', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 55, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-07T00:00:00.000Z', + daily_apy: '2.908041198804088108', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 56, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-06T00:00:00.000Z', + daily_apy: '2.145060119823427799', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 57, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-05T00:00:00.000Z', + daily_apy: '2.841421083128894904', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 58, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-04T00:00:00.000Z', + daily_apy: '3.308055911325480863', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 59, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-03T00:00:00.000Z', + daily_apy: '2.686695914242465032', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 60, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-02T00:00:00.000Z', + daily_apy: '2.536785079364388570', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 61, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-01T00:00:00.000Z', + daily_apy: '2.648720732497768971', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 62, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-30T00:00:00.000Z', + daily_apy: '2.082197028966412998', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 63, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-29T00:00:00.000Z', + daily_apy: '2.340216070995490985', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 64, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-28T00:00:00.000Z', + daily_apy: '3.319977937666746903', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 65, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-27T00:00:00.000Z', + daily_apy: '3.703776281413848230', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 66, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-26T00:00:00.000Z', + daily_apy: '3.013172805913127821', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 67, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-25T00:00:00.000Z', + daily_apy: '2.365903942695459458', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 68, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-24T00:00:00.000Z', + daily_apy: '3.071031581384699336', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 69, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-23T00:00:00.000Z', + daily_apy: '3.129977735007652931', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 70, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-22T00:00:00.000Z', + daily_apy: '2.244804237084751327', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 71, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-21T00:00:00.000Z', + daily_apy: '1.950710572454624004', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 72, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-20T00:00:00.000Z', + daily_apy: '3.239912566365526493', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 73, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-19T00:00:00.000Z', + daily_apy: '2.186565614119543639', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 74, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-18T00:00:00.000Z', + daily_apy: '2.500262242085932965', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 75, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-17T00:00:00.000Z', + daily_apy: '2.750629904747951715', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 76, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-16T00:00:00.000Z', + daily_apy: '2.276368493096229591', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 77, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-15T00:00:00.000Z', + daily_apy: '4.185305614351850885', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 78, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-14T00:00:00.000Z', + daily_apy: '4.054927851639739823', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 79, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-13T00:00:00.000Z', + daily_apy: '2.909077278596095022', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 80, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-12T00:00:00.000Z', + daily_apy: '2.307813822034531527', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 81, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-11T00:00:00.000Z', + daily_apy: '1.998366010898116261', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 82, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-10T00:00:00.000Z', + daily_apy: '2.785830933199919137', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 83, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-09T00:00:00.000Z', + daily_apy: '2.285879836319064159', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 84, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-08T00:00:00.000Z', + daily_apy: '2.473256195837844616', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 85, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-07T00:00:00.000Z', + daily_apy: '3.098427253806472069', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 86, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-06T00:00:00.000Z', + daily_apy: '2.698985513984858462', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 87, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-05T00:00:00.000Z', + daily_apy: '2.600165108949929204', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 88, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-04T00:00:00.000Z', + daily_apy: '3.047092823478128359', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 89, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-03T00:00:00.000Z', + daily_apy: '2.262961094949021405', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 90, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-02T00:00:00.000Z', + daily_apy: '2.472755536754316040', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 91, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-01T00:00:00.000Z', + daily_apy: '2.213646791454453485', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 92, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-31T00:00:00.000Z', + daily_apy: '1.995347106718121350', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 93, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-30T00:00:00.000Z', + daily_apy: '3.662338703533022788', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 94, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-29T00:00:00.000Z', + daily_apy: '3.998958366621137168', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 95, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-28T00:00:00.000Z', + daily_apy: '1.986877342949697677', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 96, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-27T00:00:00.000Z', + daily_apy: '2.759116101988913662', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 97, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-26T00:00:00.000Z', + daily_apy: '2.190927465124786394', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 98, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-25T00:00:00.000Z', + daily_apy: '2.536275873721945520', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 99, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-24T00:00:00.000Z', + daily_apy: '3.024505178229807577', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 100, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-23T00:00:00.000Z', + daily_apy: '4.579748020726853208', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 101, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-22T00:00:00.000Z', + daily_apy: '2.081328937349204369', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 102, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-21T00:00:00.000Z', + daily_apy: '2.184895085506485232', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 103, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-20T00:00:00.000Z', + daily_apy: '2.398880991852390265', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 104, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-19T00:00:00.000Z', + daily_apy: '2.858904340345552600', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 105, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-18T00:00:00.000Z', + daily_apy: '2.657885568442611670', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 106, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-17T00:00:00.000Z', + daily_apy: '3.193858105866704867', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 107, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-16T00:00:00.000Z', + daily_apy: '2.620667694611860785', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 108, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-15T00:00:00.000Z', + daily_apy: '2.257808905335986283', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 109, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-14T00:00:00.000Z', + daily_apy: '3.048205889855458503', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 110, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-13T00:00:00.000Z', + daily_apy: '3.491271068634706969', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 111, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-12T00:00:00.000Z', + daily_apy: '2.271615154691328816', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 112, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-11T00:00:00.000Z', + daily_apy: '4.405412493954814878', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 113, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-10T00:00:00.000Z', + daily_apy: '2.503050109321094303', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 114, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-09T00:00:00.000Z', + daily_apy: '2.548443687968629314', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 115, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-08T00:00:00.000Z', + daily_apy: '4.136252852174733518', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 116, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-07T00:00:00.000Z', + daily_apy: '2.485272772198996128', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 117, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-06T00:00:00.000Z', + daily_apy: '2.479277102403876272', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 118, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-05T00:00:00.000Z', + daily_apy: '2.837898081632334126', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 119, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-04T00:00:00.000Z', + daily_apy: '2.934606383096206361', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 120, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-03T00:00:00.000Z', + daily_apy: '2.321912203129618805', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 121, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-02T00:00:00.000Z', + daily_apy: '4.075864607847539878', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 122, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-01T00:00:00.000Z', + daily_apy: '3.492224428286716040', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 123, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-30T00:00:00.000Z', + daily_apy: '3.043821641472397732', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 124, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-29T00:00:00.000Z', + daily_apy: '2.613509495777192257', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 125, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-28T00:00:00.000Z', + daily_apy: '3.063007925443190708', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 126, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-27T00:00:00.000Z', + daily_apy: '2.378093521658078927', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 127, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-26T00:00:00.000Z', + daily_apy: '2.994244265878227378', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 128, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-25T00:00:00.000Z', + daily_apy: '2.745946218296348711', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 129, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-24T00:00:00.000Z', + daily_apy: '2.405584015735161504', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 130, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-23T00:00:00.000Z', + daily_apy: '2.989605996702352434', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 131, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-22T00:00:00.000Z', + daily_apy: '3.133819559469009126', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 132, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-21T00:00:00.000Z', + daily_apy: '3.281163916757090874', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 133, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-20T00:00:00.000Z', + daily_apy: '2.421092014783149226', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 134, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-19T00:00:00.000Z', + daily_apy: '2.022003308779013827', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 135, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-18T00:00:00.000Z', + daily_apy: '4.529200757864845022', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 136, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-17T00:00:00.000Z', + daily_apy: '3.814667283365271847', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 137, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-16T00:00:00.000Z', + daily_apy: '2.476474847261134347', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 138, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-15T00:00:00.000Z', + daily_apy: '2.220164627763467091', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 139, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-14T00:00:00.000Z', + daily_apy: '2.169901379130526991', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 140, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-13T00:00:00.000Z', + daily_apy: '2.541326517193025111', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 141, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-12T00:00:00.000Z', + daily_apy: '1.954328493653199558', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 142, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-11T00:00:00.000Z', + daily_apy: '4.072906334326804757', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 143, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-10T00:00:00.000Z', + daily_apy: '2.739440400339635841', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 144, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-09T00:00:00.000Z', + daily_apy: '2.273344513516595409', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 145, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-08T00:00:00.000Z', + daily_apy: '3.217442261940307633', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 146, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-07T00:00:00.000Z', + daily_apy: '4.105387731991787334', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 147, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-06T00:00:00.000Z', + daily_apy: '2.563906839247964270', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 148, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-05T00:00:00.000Z', + daily_apy: '2.380780361973878761', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 149, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-04T00:00:00.000Z', + daily_apy: '2.010176278212447677', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 150, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-03T00:00:00.000Z', + daily_apy: '2.014879575261188330', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 151, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-02T00:00:00.000Z', + daily_apy: '2.391696310813180586', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 152, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-01T00:00:00.000Z', + daily_apy: '2.691837002881187721', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 153, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-31T00:00:00.000Z', + daily_apy: '2.848137731334544082', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 154, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-30T00:00:00.000Z', + daily_apy: '2.625672748211783960', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 155, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-29T00:00:00.000Z', + daily_apy: '3.172130768572289602', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 156, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-28T00:00:00.000Z', + daily_apy: '2.224165802625017824', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 157, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-27T00:00:00.000Z', + daily_apy: '2.562155658467889340', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 158, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-26T00:00:00.000Z', + daily_apy: '2.242707816052420686', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 159, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-25T00:00:00.000Z', + daily_apy: '2.890637338773570631', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 160, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-24T00:00:00.000Z', + daily_apy: '1.953735894063520686', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 161, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-23T00:00:00.000Z', + daily_apy: '2.341905215163377655', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 162, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-22T00:00:00.000Z', + daily_apy: '2.536577572533897118', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 163, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-21T00:00:00.000Z', + daily_apy: '2.936813438123844461', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 164, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-20T00:00:00.000Z', + daily_apy: '2.331715949254856433', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 165, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-19T00:00:00.000Z', + daily_apy: '2.567146482818828263', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 166, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-18T00:00:00.000Z', + daily_apy: '4.105202614977249668', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 167, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-17T00:00:00.000Z', + daily_apy: '2.997171370184038606', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 168, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-16T00:00:00.000Z', + daily_apy: '2.407456734779822954', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 169, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-15T00:00:00.000Z', + daily_apy: '2.454080118573422788', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 170, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-14T00:00:00.000Z', + daily_apy: '2.002963922567441482', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 171, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-13T00:00:00.000Z', + daily_apy: '2.012008626631090653', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 172, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-12T00:00:00.000Z', + daily_apy: '2.028749136065413606', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 173, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-11T00:00:00.000Z', + daily_apy: '1.976160574262442644', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 174, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-10T00:00:00.000Z', + daily_apy: '1.948307879992547788', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 175, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-09T00:00:00.000Z', + daily_apy: '2.586055369837104757', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 176, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-08T00:00:00.000Z', + daily_apy: '3.923370517729219746', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 177, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-07T00:00:00.000Z', + daily_apy: '1.862965024176516372', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 178, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-06T00:00:00.000Z', + daily_apy: '2.243214854150509015', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, +]; + +export const STAKING_API_LENDING_RESPONSE = { + markets: [ + { + id: '0x6ab707aca953edaefbc4fd23ba73294241490620', + chainId: 42161, + protocol: 'aave', + name: '0x6ab707aca953edaefbc4fd23ba73294241490620', + address: '0x6ab707aca953edaefbc4fd23ba73294241490620', + netSupplyRate: 3.8161574310387487, + totalSupplyRate: 3.8161574310387487, + rewards: [], + tvlUnderlying: '94482616843290', + underlying: { + address: '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', + chainId: 42161, + }, + outputToken: { + address: '0x6ab707aca953edaefbc4fd23ba73294241490620', + chainId: 42161, + }, + }, + { + id: '0x724dc807b04555b71ed48a6896b6f41593b8c637', + chainId: 42161, + protocol: 'aave', + name: '0x724dc807b04555b71ed48a6896b6f41593b8c637', + address: '0x724dc807b04555b71ed48a6896b6f41593b8c637', + netSupplyRate: 3.5114225639239254, + totalSupplyRate: 3.5114225639239254, + rewards: [], + tvlUnderlying: '280326808784312', + underlying: { + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + chainId: 42161, + }, + outputToken: { + address: '0x724dc807b04555b71ed48a6896b6f41593b8c637', + chainId: 42161, + }, + }, + { + id: '0x82e64f49ed5ec1bc6e43dad4fc8af9bb3a2312ee', + chainId: 42161, + protocol: 'aave', + name: '0x82e64f49ed5ec1bc6e43dad4fc8af9bb3a2312ee', + address: '0x82e64f49ed5ec1bc6e43dad4fc8af9bb3a2312ee', + netSupplyRate: 3.3825609249561017, + totalSupplyRate: 3.3825609249561017, + rewards: [], + tvlUnderlying: '7029707533530581450561709', + underlying: { + address: '0xda10009cbd5d07dd0cecc66161fc93d7c9000da1', + chainId: 42161, + }, + outputToken: { + address: '0x82e64f49ed5ec1bc6e43dad4fc8af9bb3a2312ee', + chainId: 42161, + }, + }, + { + id: '0x018008bfb33d285247a21d44e50697654f754e63', + chainId: 1, + protocol: 'aave', + name: '0x018008bfb33d285247a21d44e50697654f754e63', + address: '0x018008bfb33d285247a21d44e50697654f754e63', + netSupplyRate: 3.246198558489036, + totalSupplyRate: 3.246198558489036, + rewards: [], + tvlUnderlying: '176868291129310390562110292', + underlying: { + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + chainId: 1, + }, + outputToken: { + address: '0x018008bfb33d285247a21d44e50697654f754e63', + chainId: 1, + }, + }, + { + id: '0x23878914efe38d27c4d67ab83ed1b93a74d4086a', + chainId: 1, + protocol: 'aave', + name: '0x23878914efe38d27c4d67ab83ed1b93a74d4086a', + address: '0x23878914efe38d27c4d67ab83ed1b93a74d4086a', + netSupplyRate: 3.5842156590894767, + totalSupplyRate: 3.5842156590894767, + rewards: [], + tvlUnderlying: '8075909063710562', + underlying: { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + chainId: 1, + }, + outputToken: { + address: '0x23878914efe38d27c4d67ab83ed1b93a74d4086a', + chainId: 1, + }, + }, + { + id: '0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c', + chainId: 1, + protocol: 'aave', + name: '0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c', + address: '0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c', + netSupplyRate: 4.127418405053469, + totalSupplyRate: 4.127418405053469, + rewards: [], + tvlUnderlying: '3918003902274145', + underlying: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: 1, + }, + outputToken: { + address: '0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c', + chainId: 1, + }, + }, + { + id: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + chainId: 8453, + protocol: 'aave', + name: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + address: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + netSupplyRate: 5.1724226654358745, + totalSupplyRate: 5.1724226654358745, + rewards: [], + tvlUnderlying: '200378429920868', + underlying: { + address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + chainId: 8453, + }, + outputToken: { + address: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + chainId: 8453, + }, + }, + { + id: '0x374d7860c4f2f604de0191298dd393703cce84f3', + chainId: 59144, + protocol: 'aave', + name: '0x374d7860c4f2f604de0191298dd393703cce84f3', + address: '0x374d7860c4f2f604de0191298dd393703cce84f3', + netSupplyRate: 3.6878492921828356, + totalSupplyRate: 3.6878492921828356, + rewards: [], + tvlUnderlying: '1875214972417', + underlying: { + address: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + chainId: 59144, + }, + outputToken: { + address: '0x374d7860c4f2f604de0191298dd393703cce84f3', + chainId: 59144, + }, + }, + { + id: '0x88231dfec71d4ff5c1e466d08c321944a7adc673', + chainId: 59144, + protocol: 'aave', + name: '0x88231dfec71d4ff5c1e466d08c321944a7adc673', + address: '0x88231dfec71d4ff5c1e466d08c321944a7adc673', + netSupplyRate: 3.890851460575282, + totalSupplyRate: 3.890851460575282, + rewards: [], + tvlUnderlying: '550394667084', + underlying: { + address: '0xa219439258ca9da29e9cc4ce5596924745e12b93', + chainId: 59144, + }, + outputToken: { + address: '0x88231dfec71d4ff5c1e466d08c321944a7adc673', + chainId: 59144, + }, + }, + ], +}; diff --git a/e2e/api-mocking/mock-responses/token-api-responses.ts b/e2e/api-mocking/mock-responses/token-api-responses.ts new file mode 100644 index 00000000000..7c31a81fc52 --- /dev/null +++ b/e2e/api-mocking/mock-responses/token-api-responses.ts @@ -0,0 +1,293 @@ +export const TOKEN_API_TOKENS_RESPONSE = [ + { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + symbol: 'USDT', + decimals: 6, + name: 'Tether USD', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0xdac17f958d2ee523a2206206994597c13d831ec7.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'coinGecko', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 17, + }, + { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6, + name: 'USDCoin', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'coinGecko', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 17, + }, + { + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + symbol: 'DAI', + decimals: 18, + name: 'Dai Stablecoin', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0x6b175474e89094c44da98b954eedeac495271d0f.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'cmc', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 19, + }, + { + address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2', + symbol: 'SUSHI', + decimals: 18, + name: 'Sushi', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0x6b3595068778dd592e39a122f4f5a5cf09c90fe2.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'cmc', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 18, + }, + { + address: '0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9', + symbol: 'AAVE', + decimals: 18, + name: 'Aave', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'cmc', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 19, + }, + { + address: '0x111111111117dc0aa78b770fa6a738034120c302', + symbol: '1INCH', + decimals: 18, + name: '1inch', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0x111111111117dc0aa78b770fa6a738034120c302.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 17, + }, + { + address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + symbol: 'WETH', + decimals: 18, + name: 'Wrapped Ether', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + ], + occurrences: 17, + }, + { + address: '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', + symbol: 'WBTC', + decimals: 8, + name: 'Wrapped BTC', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0x2260fac5e5542a773aa44fbcfedf7c193bc2c599.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'cmc', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 19, + }, + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + name: 'ChainLink Token', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0x514910771af9ca656af840dff83e8264ecf986ca.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'cmc', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 19, + }, + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + symbol: 'UNI', + decimals: 18, + name: 'Uniswap', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0x1f9840a85d5af5bf1d1762f925bdaddc4201f984.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'cmc', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 19, + }, +]; diff --git a/e2e/framework/fixtures/FixtureHelper.ts b/e2e/framework/fixtures/FixtureHelper.ts index 129da74987a..c5bb7db58c7 100644 --- a/e2e/framework/fixtures/FixtureHelper.ts +++ b/e2e/framework/fixtures/FixtureHelper.ts @@ -41,6 +41,7 @@ import { mockNotificationServices } from '../../specs/notifications/utils/mocks' import { type Mockttp } from 'mockttp'; import { Buffer } from 'buffer'; import crypto from 'crypto'; +import { DEFAULT_MOCKS } from '../../api-mocking/default-mocks'; const logger = createLogger({ name: 'FixtureHelper', @@ -320,6 +321,48 @@ export const stopFixtureServer = async (fixtureServer: FixtureServer) => { logger.debug('The fixture server is stopped'); }; +/** + * Merges test-specific mocks with default mocks, prioritizing test-specific mocks + * @param testSpecificMocks - Test-specific mock events organized by method + * @returns Merged mock events with test-specific mocks taking priority + */ +const mergeWithDefaultMocks = ( + testSpecificMocks: TestSpecificMock | undefined, +) => { + if (!testSpecificMocks) { + return DEFAULT_MOCKS; + } + + const mergedMocks: TestSpecificMock = {}; + + // Get all HTTP methods from both test-specific and default mocks + const allMethods = new Set([ + ...Object.keys(testSpecificMocks), + ...Object.keys(DEFAULT_MOCKS), + ]); + + allMethods.forEach((method) => { + const testMocks = testSpecificMocks[method as keyof TestSpecificMock] || []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const defaultMocks = (DEFAULT_MOCKS as any)[method] || []; + + // Create a set of URLs that already exist in test-specific mocks + const testMockUrls = new Set(testMocks.map((mock) => mock.urlEndpoint)); + + // Filter out default mocks that have the same URL as test-specific mocks + const filteredDefaultMocks = defaultMocks.filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (defaultMock: any) => !testMockUrls.has(defaultMock.urlEndpoint), + ); + + // Merge test-specific mocks first, then append non-duplicate default mocks + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mergedMocks as any)[method] = [...testMocks, ...filteredDefaultMocks]; + }); + + return mergedMocks; +}; + export const createMockAPIServer = async ( mockServerInstance?: Mockttp, testSpecificMock?: TestSpecificMock, @@ -351,7 +394,8 @@ export const createMockAPIServer = async ( // testSpecificMock only if (!mockServerInstance && testSpecificMock) { mockServerPort = getMockServerPort(); - mockServer = await startMockServer(testSpecificMock, mockServerPort); + const mergedMocks = mergeWithDefaultMocks(testSpecificMock); + mockServer = await startMockServer(mergedMocks, mockServerPort); logger.debug( `Mock server started from testSpecificMock on port ${mockServerPort}`, @@ -361,7 +405,8 @@ export const createMockAPIServer = async ( // neither if (!mockServerInstance && !testSpecificMock) { mockServerPort = getMockServerPort(); - mockServer = await startMockServer({}, mockServerPort); + const mergedMocks = mergeWithDefaultMocks(testSpecificMock); + mockServer = await startMockServer(mergedMocks, mockServerPort); logger.debug( `Mock server started from testSpecificMock on port ${mockServerPort}`, diff --git a/e2e/specs/assets/multichain/asset-list.spec.ts b/e2e/specs/assets/multichain/asset-list.spec.ts index c80a15b082c..863719bc57d 100644 --- a/e2e/specs/assets/multichain/asset-list.spec.ts +++ b/e2e/specs/assets/multichain/asset-list.spec.ts @@ -68,6 +68,7 @@ describe(SmokeNetworkAbstractions('Import Tokens'), () => { await WalletView.tapTokenNetworkFilter(); await WalletView.tapTokenNetworkFilterAll(); const avax = WalletView.tokenInWallet('AVAX'); + await WalletView.scrollToToken('AVAX'); await Assertions.expectElementToBeVisible(avax); await WalletView.tapOnToken('AVAX'); await Assertions.expectElementToBeVisible(TokenOverview.sendButton); @@ -120,9 +121,7 @@ describe(SmokeNetworkAbstractions('Import Tokens'), () => { await WalletView.tapTokenNetworkFilter(); await WalletView.tapTokenNetworkFilterAll(); - if (device.getPlatform() === 'ios') { - await WalletView.scrollToToken('AVAX', 'up'); - } + await WalletView.scrollToToken('AVAX'); await WalletView.tapOnToken('AVAX'); await Assertions.expectElementToBeVisible(TokenOverview.container); From 0a9c37852b7c79e3834cb15576d65ed00fde6bdb Mon Sep 17 00:00:00 2001 From: Priya Date: Mon, 18 Aug 2025 15:01:14 +0200 Subject: [PATCH 2/3] test: quarantine flaky browser tests (#18432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../browser/browser-tests.failing.ts} | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) rename e2e/specs/{browser/browser-tests.spec.ts => quarantine/browser/browser-tests.failing.ts} (85%) diff --git a/e2e/specs/browser/browser-tests.spec.ts b/e2e/specs/quarantine/browser/browser-tests.failing.ts similarity index 85% rename from e2e/specs/browser/browser-tests.spec.ts rename to e2e/specs/quarantine/browser/browser-tests.failing.ts index bf821cf9e44..680ce661e37 100644 --- a/e2e/specs/browser/browser-tests.spec.ts +++ b/e2e/specs/quarantine/browser/browser-tests.failing.ts @@ -1,22 +1,22 @@ -import { SmokeWalletPlatform } from '../../tags'; -import { loginToApp } from '../../viewHelper'; -import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../framework/fixtures/FixtureHelper'; -import ExternalSites from '../../resources/externalsites.json'; -import Browser from '../../pages/Browser/BrowserView'; -import EnsWebsite from '../../pages/Browser/ExternalWebsites/EnsWebsite.ts'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; -import Assertions from '../../framework/Assertions'; -import ConnectBottomSheet from '../../pages/Browser/ConnectBottomSheet.ts'; -import RedirectWebsite from '../../pages/Browser/ExternalWebsites/RedirectWebsite.ts'; -import UniswapWebsite from '../../pages/Browser/ExternalWebsites/UniswapWebsite.ts'; -import OpenseaWebsite from '../../pages/Browser/ExternalWebsites/OpenseaWebsite.ts'; -import PancakeSwapWebsite from '../../pages/Browser/ExternalWebsites/PancakeSwapWebsite.ts'; -import DownloadFile from '../../pages/Browser/DownloadFile.ts'; -import DownloadFileWebsite from '../../pages/Browser/ExternalWebsites/DownloadFileWebsite.ts'; -import TestHelpers from '../../helpers'; -import CameraWebsite from '../../pages/Browser/ExternalWebsites/Security/CameraWebsite.ts'; -import HistoryDisclosureWebsite from '../../pages/Browser/ExternalWebsites/Security/HistoryDisclosureWebsite.ts'; +import { SmokeWalletPlatform } from '../../../tags.js'; +import { loginToApp } from '../../../viewHelper.ts'; +import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder.ts'; +import { withFixtures } from '../../../framework/fixtures/FixtureHelper.ts'; +import ExternalSites from '../../../resources/externalsites.json'; +import Browser from '../../../pages/Browser/BrowserView.ts'; +import EnsWebsite from '../../../pages/Browser/ExternalWebsites/EnsWebsite.ts'; +import TabBarComponent from '../../../pages/wallet/TabBarComponent.ts'; +import Assertions from '../../../framework/Assertions.ts'; +import ConnectBottomSheet from '../../../pages/Browser/ConnectBottomSheet.ts'; +import RedirectWebsite from '../../../pages/Browser/ExternalWebsites/RedirectWebsite.ts'; +import UniswapWebsite from '../../../pages/Browser/ExternalWebsites/UniswapWebsite.ts'; +import OpenseaWebsite from '../../../pages/Browser/ExternalWebsites/OpenseaWebsite.ts'; +import PancakeSwapWebsite from '../../../pages/Browser/ExternalWebsites/PancakeSwapWebsite.ts'; +import DownloadFile from '../../../pages/Browser/DownloadFile.ts'; +import DownloadFileWebsite from '../../../pages/Browser/ExternalWebsites/DownloadFileWebsite.ts'; +import TestHelpers from '../../../helpers.js'; +import CameraWebsite from '../../../pages/Browser/ExternalWebsites/Security/CameraWebsite.ts'; +import HistoryDisclosureWebsite from '../../../pages/Browser/ExternalWebsites/Security/HistoryDisclosureWebsite.ts'; const getHostFromURL = (url: string): string => { try { From 6fb3cb3a94abd45a04b857a0d50e21357d3dce0c Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Tue, 19 Aug 2025 01:10:42 +0800 Subject: [PATCH 3/3] feat: add real-time WebSocket streaming to Perps with performance optimizations (#18430) ## **Description** This PR implements WebSocket-based real-time data streaming for the Perps feature, replacing the previous polling-based approach. The implementation evolved significantly from the original plan to include critical performance optimizations and architectural improvements that eliminate unnecessary re-renders and provide flexible update control. **Key improvements:** 1. **Pure WebSocket Architecture**: Migrated from hybrid REST/WebSocket to pure WebSocket for all live data (prices, positions, orders, fills) 2. **Flexible Throttling**: Made throttling optional with smart defaults - instant updates for user actions (orders/positions), throttled for high-frequency data (prices) 3. **Re-render Optimization**: Created isolated leaf components for frequently updating data to prevent parent component re-renders 4. **TP/SL Fix**: Fixed root cause of Take Profit/Stop Loss data not displaying by extracting from `triggerPx` field instead of broken `isPositionTpsl` flag 5. **WebSocket Pre-warming**: Pre-establishes positions and orders subscriptions when entering Perps environment to eliminate empty initial states ## **Changelog** CHANGELOG entry: Fixed Perps live data updates and significantly improved performance by implementing WebSocket streaming with optimized re-rendering **Latest fixes:** - Eliminated duplicate WebSocket subscriptions by implementing shared webData2 connection for positions and orders - Single WebSocket connection now provides both positions (with TP/SL) and orders data - Fixed pre-warming to create persistent subscriptions that stay alive throughout Perps session - Pre-warm subscriptions now use no-op callbacks to maintain connections and continuous caching - Fixed reference counting with separate position/order subscriber tracking to prevent premature disconnection ## **Related issues** Fixes: Implementation of PR#3 from perps_stream_architecture_v3.md plan ## **Manual testing steps** ```gherkin Feature: Perps WebSocket Streaming Scenario: User views live price updates without UI lag Given user is on the Perps Market Details view And market prices are changing rapidly When user observes the price display Then prices update smoothly every 1-2 seconds And parent components do not re-render And UI remains responsive Scenario: User places and cancels orders with instant feedback Given user has the Orders tab open in Market Details When user places a new order Then order appears instantly in the list without delay When user cancels an order Then order disappears instantly from the list Scenario: User views positions with TP/SL values Given user has open positions with Take Profit and Stop Loss set When user views the Positions tab Then TP/SL values are displayed correctly for each position And values update in real-time via WebSocket Scenario: Positions are available immediately on mount Given user navigates to Perps environment When any component requests positions data Then positions are available immediately from cache And there is no empty state for ~10 seconds And components render with data from the start Scenario: Funding countdown updates without parent re-renders Given user is viewing a market with funding payments When funding countdown timer ticks Then only the countdown text updates And parent components remain stable (no re-renders) ``` ## **Screenshots/Recordings** ### **Before** - Excessive re-renders every second from funding countdown - 30-second delay for order cancellation updates - TP/SL values not displaying (isPositionTpsl always false) - Parent components re-rendering on every price update - Empty positions array for ~10 seconds on initial mount ### **After** - Isolated countdown component - no parent re-renders - Instant order updates (0ms throttle) - TP/SL values correctly extracted and displayed - Leaf components handle updates without affecting parents - Positions and orders available immediately from pre-warmed cache ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- ## Technical Details ### Files Changed Summary **New Components (3):** - `FundingCountdown` - Isolated timer component preventing parent re-renders - `LivePriceDisplay` - Real-time price display component - `LivePriceHeader` - Market header with live price updates **New Stream Hooks (4):** - `usePerpsLiveOrders` - Real-time order updates (0ms default throttle) - `usePerpsLivePositions` - Real-time position updates (0ms default throttle) - `usePerpsLivePrices` - Real-time price updates (1000ms default throttle) - `usePerpsLiveFills` - Real-time fill updates (0ms default throttle) **Removed Polling Hooks (2):** - `usePerpsOpenOrders` - Replaced by `usePerpsLiveOrders` - `usePerpsPositions` - Replaced by `usePerpsLivePositions` **Core Infrastructure:** - `PerpsStreamManager` - Enhanced with optional throttling - `HyperLiquidSubscriptionService` - Pure WebSocket implementation ### Performance Metrics | Metric | Before | After | Improvement | |--------|--------|-------|-------------| | WebSocket Connections | N per component | 1 per data type | ~90% reduction | | Parent Re-renders | Every update | Never | 100% reduction | | Order Update Latency | 30 seconds | Instant | 30s improvement | | Price Update Frequency | Uncontrolled | 1-2 seconds | Balanced | ### Architecture Improvements 1. **Optional Throttling Pattern** ```typescript // Instant updates for user actions const orders = usePerpsLiveOrders({ throttleMs: 0 }); // default // Throttled updates for high-frequency data const prices = usePerpsLivePrices({ throttleMs: 1000 }); // default ``` 2. **Leaf Component Pattern** ```typescript // Before: Parent re-renders every second {fundingCountdown} // After: Only leaf component re-renders ``` 3. **TP/SL Data Extraction** ```typescript // Fixed: Extract from triggerPx instead of broken isPositionTpsl if (order.triggerPx) { if (order.orderType?.includes('Take Profit')) { existing.takeProfitPrice = order.triggerPx; } else if (order.orderType?.includes('Stop')) { existing.stopLossPrice = order.triggerPx; } } ``` 4. **Persistent Pre-warming with Single Connection** ```typescript // Pre-warm creates REAL subscriptions that persist throughout session public prewarm(): () => void { // Creates subscription with no-op callback to keep connection alive this.prewarmUnsubscribe = this.subscribe({ callback: () => {}, // Keeps connection alive for caching throttleMs: 0 }); return this.prewarmUnsubscribe; // Cleanup function for when leaving Perps } ``` 5. **Shared webData2 Subscription with Proper Reference Counting** ```typescript // Separate counters for positions and orders prevent premature disconnection private positionSubscriberCount = 0; private orderSubscriberCount = 0; // Only cleanup when BOTH have zero subscribers private cleanupSharedWebData2Subscription(): void { const totalSubscribers = this.positionSubscriberCount + this.orderSubscriberCount; if (totalSubscribers <= 0 && this.sharedWebData2Subscription) { // Safe to disconnect - no subscribers left } } ``` --------- Co-authored-by: Claude --- app/components/UI/Perps/PERPS_ARCH.md | 30 +- .../UI/Perps/PERPS_NAVIGATION_ARCHITECTURE.md | 250 ++++++++++ .../PerpsMarketDetailsView.test.tsx | 7 +- .../PerpsMarketDetailsView.tsx | 37 +- .../Views/PerpsOrderView/PerpsOrderView.tsx | 6 +- .../PerpsPositionsView.test.tsx | 114 ++--- .../PerpsPositionsView/PerpsPositionsView.tsx | 39 +- .../Views/PerpsTabView/PerpsTabView.test.tsx | 92 ++-- .../Perps/Views/PerpsTabView/PerpsTabView.tsx | 50 +- .../UI/Perps/Views/PerpsView.test.tsx | 5 +- app/components/UI/Perps/Views/PerpsView.tsx | 8 +- .../FundingCountdown.test.tsx | 98 ++++ .../FundingCountdown/FundingCountdown.tsx | 65 +++ .../components/FundingCountdown/index.ts | 1 + .../LivePriceDisplay.test.tsx | 198 ++++++++ .../LivePriceDisplay/LivePriceDisplay.tsx | 71 +++ .../LivePriceDisplay/LivePriceHeader.test.tsx | 257 +++++++++++ .../LivePriceDisplay/LivePriceHeader.tsx | 96 ++++ .../components/LivePriceDisplay/index.ts | 1 + .../PerpsClosePositionBottomSheet.test.tsx | 2 +- .../PerpsClosePositionBottomSheet.tsx | 6 +- .../PerpsLimitPriceBottomSheet.test.tsx | 24 +- .../PerpsLimitPriceBottomSheet.tsx | 8 +- .../PerpsMarketHeader.test.tsx | 9 +- .../PerpsMarketHeader/PerpsMarketHeader.tsx | 43 +- .../PerpsMarketStatisticsCard.test.tsx | 13 - .../PerpsMarketStatisticsCard.tsx | 12 +- .../PerpsMarketStatisticsCard.types.ts | 8 + .../PerpsMarketTabs/PerpsMarketTabs.test.tsx | 2 - .../PerpsMarketTabs/PerpsMarketTabs.tsx | 22 +- .../PerpsMarketTabs/PerpsMarketTabs.types.ts | 11 +- .../PerpsOpenOrderCard/PerpsOpenOrderCard.tsx | 12 +- .../PerpsOpenOrderCard.types.ts | 2 +- .../PerpsPositionCard.test.tsx | 87 +--- .../PerpsPositionCard/PerpsPositionCard.tsx | 30 +- .../PerpsTPSLBottomSheet.test.tsx | 2 +- .../PerpsTPSLBottomSheet.tsx | 6 +- .../PerpsTabControlBar.test.tsx | 129 +----- .../PerpsTabControlBar/PerpsTabControlBar.tsx | 76 ++-- .../UI/Perps/controllers/PerpsController.ts | 9 + .../providers/HyperLiquidProvider.test.ts | 6 +- .../providers/HyperLiquidProvider.ts | 73 +-- .../UI/Perps/controllers/types/index.ts | 17 +- app/components/UI/Perps/hooks/index.ts | 2 - app/components/UI/Perps/hooks/stream/index.ts | 188 +------- .../Perps/hooks/stream/useLiveFills.test.ts | 46 +- .../Perps/hooks/stream/useLiveOrders.test.ts | 42 +- .../hooks/stream/useLivePositions.test.ts | 94 ++-- .../Perps/hooks/stream/useLivePrices.test.ts | 53 +-- .../Perps/hooks/stream/usePerpsLiveFills.ts | 51 +++ .../Perps/hooks/stream/usePerpsLiveOrders.ts | 44 ++ .../hooks/stream/usePerpsLivePositions.ts | 82 ++++ .../Perps/hooks/stream/usePerpsLivePrices.ts | 52 +++ .../hooks/useHasExistingPosition.test.ts | 111 ++--- .../UI/Perps/hooks/useHasExistingPosition.ts | 38 +- .../Perps/hooks/usePerpsMarketStats.test.ts | 67 +-- .../UI/Perps/hooks/usePerpsMarketStats.ts | 72 ++- .../UI/Perps/hooks/usePerpsMarkets.test.ts | 17 +- .../UI/Perps/hooks/usePerpsMarkets.ts | 6 +- .../UI/Perps/hooks/usePerpsOpenOrders.test.ts | 388 ---------------- .../UI/Perps/hooks/usePerpsOrders.test.ts | 116 +---- .../UI/Perps/hooks/usePerpsOrders.ts | 30 +- .../Perps/hooks/usePerpsPositionData.test.ts | 61 +-- .../UI/Perps/hooks/usePerpsPositionData.ts | 32 -- .../UI/Perps/hooks/usePerpsPositions.test.ts | 128 ------ .../UI/Perps/hooks/usePerpsPrices.ts | 6 +- .../providers/PerpsStreamManager.test.tsx | 426 +++++++++++++++++- .../UI/Perps/providers/PerpsStreamManager.tsx | 158 ++++--- .../__mocks__/PerpsStreamManager.tsx | 31 ++ .../HyperLiquidSubscriptionService.test.ts | 204 ++++++++- .../HyperLiquidSubscriptionService.ts | 276 ++++++++++-- .../Perps/services/PerpsConnectionManager.ts | 88 ++++ .../UI/Perps/utils/hyperLiquidAdapter.test.ts | 394 ++++++++++++++++ .../UI/Perps/utils/hyperLiquidAdapter.ts | 86 ++++ .../Perps/utils/marketDataTransform.test.ts | 97 +++- .../UI/Perps/utils/marketDataTransform.ts | 30 +- .../UI/Perps/utils/marketUtils.test.ts | 85 ++++ app/components/UI/Perps/utils/marketUtils.ts | 39 +- locales/languages/en.json | 3 + 79 files changed, 3692 insertions(+), 1985 deletions(-) create mode 100644 app/components/UI/Perps/PERPS_NAVIGATION_ARCHITECTURE.md create mode 100644 app/components/UI/Perps/components/FundingCountdown/FundingCountdown.test.tsx create mode 100644 app/components/UI/Perps/components/FundingCountdown/FundingCountdown.tsx create mode 100644 app/components/UI/Perps/components/FundingCountdown/index.ts create mode 100644 app/components/UI/Perps/components/LivePriceDisplay/LivePriceDisplay.test.tsx create mode 100644 app/components/UI/Perps/components/LivePriceDisplay/LivePriceDisplay.tsx create mode 100644 app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx create mode 100644 app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx create mode 100644 app/components/UI/Perps/components/LivePriceDisplay/index.ts create mode 100644 app/components/UI/Perps/hooks/stream/usePerpsLiveFills.ts create mode 100644 app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.ts create mode 100644 app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts create mode 100644 app/components/UI/Perps/hooks/stream/usePerpsLivePrices.ts delete mode 100644 app/components/UI/Perps/hooks/usePerpsOpenOrders.test.ts delete mode 100644 app/components/UI/Perps/hooks/usePerpsPositions.test.ts create mode 100644 app/components/UI/Perps/providers/__mocks__/PerpsStreamManager.tsx diff --git a/app/components/UI/Perps/PERPS_ARCH.md b/app/components/UI/Perps/PERPS_ARCH.md index 45c8ffaf966..0419bf8a0ef 100644 --- a/app/components/UI/Perps/PERPS_ARCH.md +++ b/app/components/UI/Perps/PERPS_ARCH.md @@ -78,11 +78,32 @@ Before creating a new hook: Single WebSocket subscriptions shared across all components with component-level debouncing. This prevents subscription interference and reduces WebSocket connections by 90%. +### WebSocket Pre-warming (Persistent Connections) + +Pre-warming creates persistent subscriptions that stay alive throughout the Perps session: + +- **Problem**: WebSocket subscriptions start on-demand, causing ~10 second delays before data arrives +- **Solution**: Create persistent subscriptions with no-op callbacks when entering Perps environment +- **Implementation**: + - `prewarm()` creates actual subscriptions that keep connections alive + - `PerpsConnectionManager` stores cleanup functions and only calls them when leaving Perps +- **Result**: Connections stay alive, cache continuously populated, instant data for all components + +### Single WebSocket Connection Architecture + +To minimize network overhead and ensure data consistency: + +- **Shared webData2**: Single subscription provides both positions (with TP/SL) and orders data +- **Reference Counting**: Tracks subscriber count to maintain connection while needed +- **Automatic Cleanup**: Disconnects when last subscriber unsubscribes +- **Result**: One WebSocket connection per data type instead of per component + ### Provider Setup - `PerpsStreamProvider` wraps all routes in `/routes/index.tsx` - Provides access to stream channels without holding state - No re-renders propagated to parent components +- `PerpsConnectionManager` pre-loads critical subscriptions on connection ### Stream Hooks @@ -92,13 +113,13 @@ Located in `/hooks/stream/`: // Each component sets its own update rate const prices = useLivePrices({ symbols: ['BTC', 'ETH'], - debounceMs: 10000, // 10s for order view + throttleMs: 10000, // 10s for order view }); ``` Available hooks: -- `useLivePrices(options)` - Real-time prices with custom debounce +- `useLivePrices(options)` - Real-time prices with custom throttle - `useLiveOrders(options)` - Order updates (future) - `useLivePositions(options)` - Position updates (future) - `useLiveFills(options)` - Fill notifications (future) @@ -110,11 +131,12 @@ Available hooks: - **Component-level control** - Different rates for different views - **Instant first render** - Cached data available immediately - **Zero parent re-renders** - Updates go directly to subscribers +- **No empty initial states** - Pre-warmed subscriptions provide data immediately ### Migration Path 1. Replace `usePerpsPrices` with `useLivePrices` -2. Set appropriate debounce for each view: +2. Set appropriate throttle for each view: - Order entry: 10000ms (stable prices) - Market list: 2000ms (responsive updates) - Market details: 500ms (near real-time) @@ -130,6 +152,8 @@ Available hooks: ├─────────────────────────────────────┤ │ Stream Manager (WebSocket) │ <- NEW LAYER ├─────────────────────────────────────┤ +│ Connection Manager (Pre-warming) │ <- NEW LAYER +├─────────────────────────────────────┤ │ Controller (Business) │ ├─────────────────────────────────────┤ │ Provider (Protocol) │ diff --git a/app/components/UI/Perps/PERPS_NAVIGATION_ARCHITECTURE.md b/app/components/UI/Perps/PERPS_NAVIGATION_ARCHITECTURE.md new file mode 100644 index 00000000000..74194bc3a57 --- /dev/null +++ b/app/components/UI/Perps/PERPS_NAVIGATION_ARCHITECTURE.md @@ -0,0 +1,250 @@ +# Perps Navigation Architecture + +> Visual documentation of the MetaMask Mobile Perps feature navigation flow and screen relationships + +## 📊 Navigation Flow Diagram + +```mermaid +graph TB + %% Entry Points + Start[App Start] --> MainTab[Main Tab Navigation] + MainTab --> PerpsRoot[PERPS.ROOT] + + %% Main Hub - PerpsView (Trading View) + PerpsRoot --> TradingView[PERPS.TRADING_VIEW
PerpsView] + + %% Primary Navigation from Trading View + TradingView --> Markets[PERPS.MARKETS
PerpsMarketListView] + TradingView --> Positions[PERPS.POSITIONS
PerpsPositionsView] + TradingView --> Withdraw[PERPS.WITHDRAW
PerpsWithdrawView] + TradingView --> Order[PERPS.ORDER
PerpsOrderView] + + %% Market Flow + Markets --> MarketDetails[PERPS.MARKET_DETAILS
PerpsMarketDetailsView] + Markets --> Tutorial[PERPS.TUTORIAL
PerpsTutorialCarousel] + + %% Market Details Actions + MarketDetails --> Order + MarketDetails --> DepositFlow[Deposit Flow
via Confirmations] + + %% Order Flow + Order --> QuoteExpired[PERPS.MODALS.QUOTE_EXPIRED_MODAL
PerpsQuoteExpiredModal] + Order --> BackToMarketDetails[Back to Market Details] + + %% Transaction History (Not directly linked in navigation) + Transactions[Transaction Views
Not in main flow] -.-> PositionTx[PERPS.POSITION_TRANSACTION] + Transactions -.-> OrderTx[PERPS.ORDER_TRANSACTION] + Transactions -.-> FundingTx[PERPS.FUNDING_TRANSACTION] + + %% Styling + classDef mainView fill:#e1f5e1,stroke:#4caf50,stroke-width:3px + classDef secondaryView fill:#e3f2fd,stroke:#2196f3,stroke-width:2px + classDef modalView fill:#fff3e0,stroke:#ff9800,stroke-width:2px + classDef unusedView fill:#ffebee,stroke:#f44336,stroke-width:2px,stroke-dasharray: 5 5 + + class TradingView mainView + class Markets,Positions,MarketDetails secondaryView + class QuoteExpired,Tutorial modalView + class Transactions,PositionTx,OrderTx,FundingTx unusedView +``` + +## 🏗️ Screen Hierarchy + +### Main Stack + +``` +Routes.PERPS.ROOT +├── Routes.PERPS.TRADING_VIEW (PerpsView) - Main Hub +│ ├── → Routes.PERPS.MARKETS +│ ├── → Routes.PERPS.POSITIONS +│ ├── → Routes.PERPS.WITHDRAW +│ └── → Routes.PERPS.ORDER +│ +├── Routes.PERPS.MARKETS (PerpsMarketListView) +│ ├── → Routes.PERPS.MARKET_DETAILS +│ └── → Routes.PERPS.TUTORIAL +│ +├── Routes.PERPS.MARKET_DETAILS (PerpsMarketDetailsView) +│ ├── → Routes.PERPS.ORDER (Long/Short) +│ └── → Deposit Flow (via Confirmations) +│ +├── Routes.PERPS.ORDER (PerpsOrderView) +│ └── → Routes.PERPS.MODALS.QUOTE_EXPIRED_MODAL +│ +└── Routes.PERPS.WITHDRAW (PerpsWithdrawView) +``` + +### Modal Stack + +``` +Routes.PERPS.MODALS.ROOT +└── Routes.PERPS.MODALS.QUOTE_EXPIRED_MODAL (PerpsQuoteExpiredModal) +``` + +## 📋 Route Usage Analysis + +| Route | Component | Used In | Status | +| ---------------------------------- | ---------------------------- | ---------------- | ----------- | +| `PERPS.ROOT` | Navigation Root | App entry | ✅ Active | +| `PERPS.TRADING_VIEW` | PerpsView | Initial route | ✅ Active | +| `PERPS.MARKETS` | PerpsMarketListView | PerpsView | ✅ Active | +| `PERPS.MARKET_DETAILS` | PerpsMarketDetailsView | MarketListView | ✅ Active | +| `PERPS.POSITIONS` | PerpsPositionsView | PerpsView | ✅ Active | +| `PERPS.ORDER` | PerpsOrderView | Multiple screens | ✅ Active | +| `PERPS.WITHDRAW` | PerpsWithdrawView | PerpsView | ✅ Active | +| `PERPS.TUTORIAL` | PerpsTutorialCarousel | MarketListView | ✅ Active | +| `PERPS.MODALS.QUOTE_EXPIRED_MODAL` | PerpsQuoteExpiredModal | OrderView | ✅ Active | +| `PERPS.DEPOSIT` | - | Routes only | ⚠️ Unused | +| `PERPS.POSITION_DETAILS` | - | Routes only | ⚠️ Unused | +| `PERPS.ORDER_HISTORY` | - | Routes only | ⚠️ Unused | +| `PERPS.ORDER_DETAILS` | - | Routes only | ⚠️ Unused | +| `PERPS.POSITION_TRANSACTION` | PerpsPositionTransactionView | TransactionsView | ❓ Orphaned | +| `PERPS.ORDER_TRANSACTION` | PerpsOrderTransactionView | TransactionsView | ❓ Orphaned | +| `PERPS.FUNDING_TRANSACTION` | PerpsFundingTransactionView | TransactionsView | ❓ Orphaned | + +## 🔄 Navigation Patterns + +### 1. **Main Trading Hub Pattern** + +``` +PerpsView (Trading View) + ├── View Markets → PerpsMarketListView + ├── View Positions → PerpsPositionsView + ├── Withdraw → PerpsWithdrawView + └── Quick Trade → PerpsOrderView +``` + +### 2. **Market Discovery Pattern** + +``` +PerpsMarketListView + ├── Select Market → PerpsMarketDetailsView + └── Tutorial → PerpsTutorialCarousel +``` + +### 3. **Trading Execution Pattern** + +``` +PerpsMarketDetailsView + ├── Long → PerpsOrderView (direction: 'long') + ├── Short → PerpsOrderView (direction: 'short') + └── Add Funds → Confirmations Screen +``` + +## 🧩 Key Components Usage + +### Tab Components (PerpsTabView) + +- **Location**: Embedded in PerpsView +- **Purpose**: Main navigation hub with tabs +- **Tabs**: Portfolio, Markets, Orders, Transactions + +### Market Components + +- **PerpsMarketCard**: Used in MarketListView +- **PerpsMarketHeader**: Used in MarketDetailsView +- **PerpsMarketTabs**: Used in MarketDetailsView (Position/Orders/Stats) + +### Position Components + +- **PerpsPositionCard**: Used in PositionsView, MarketTabs +- **PerpsPositionSummary**: Used in PerpsView + +### Order Components + +- **PerpsOpenOrderCard**: Used in MarketTabs, OrdersView +- **PerpsOrderConfirmation**: Used in OrderView + +## 🔍 Potential Cleanup Opportunities + +### 1. **Unused Routes** (Can be removed from Routes.ts) + +- `PERPS.DEPOSIT` - No implementation found +- `PERPS.POSITION_DETAILS` - No implementation found +- `PERPS.ORDER_HISTORY` - No implementation found +- `PERPS.ORDER_DETAILS` - No implementation found + +### 2. **Orphaned Transaction Views** + +- `PerpsTransactionsView` - Parent component exists but not navigated to +- `PerpsPositionTransactionView` - Child view not accessible +- `PerpsOrderTransactionView` - Child view not accessible +- `PerpsFundingTransactionView` - Child view not accessible + +**Note**: These transaction views might be intended for future use or are accessed through a different flow not visible in the main navigation. + +### 3. **Refactoring Opportunities** + +- **PerpsTabView**: Consider if this needs to be a separate view or can be integrated +- **Transaction Views**: Either implement navigation or remove if not needed + +## 📱 Screen Flow Examples + +### Example 1: Opening a Position + +``` +1. PerpsView (Trading View) +2. → PerpsMarketListView (Browse Markets) +3. → PerpsMarketDetailsView (Select SOL) +4. → PerpsOrderView (Long/Short) +5. → Confirm → Back to PerpsMarketDetailsView +``` + +### Example 2: Managing Positions + +``` +1. PerpsView (Trading View) +2. → PerpsPositionsView (View All Positions) +3. → Select Position → Actions (Close/Edit) +``` + +### Example 3: First Time User + +``` +1. PerpsView (Trading View) +2. → PerpsMarketListView +3. → PerpsTutorialCarousel (Tutorial) +4. → Back to Markets +``` + +## 🎯 Recommendations + +1. **Remove unused routes** from `Routes.ts` to clean up the codebase +2. **Investigate transaction views** - Either implement proper navigation or remove if deprecated +3. **Consider consolidating** PerpsTabView functionality if it's only used in one place +4. **Document intended use** for transaction views if they're for future features +5. **Add navigation tests** to ensure all routes are accessible and working + +## 📊 Component Dependencies + +```mermaid +graph LR + subgraph Providers + ConnectionProvider[PerpsConnectionProvider] + StreamProvider[PerpsStreamProvider] + end + + subgraph Views + PerpsView + MarketListView + MarketDetailsView + PositionsView + OrderView + end + + subgraph Components + MarketCard + PositionCard + OrderCard + MarketTabs + end + + ConnectionProvider --> Views + StreamProvider --> Views + Views --> Components +``` + +--- + +_Last Updated: January 2025_ +_Note: This documentation reflects the current state of the codebase. Some routes exist in Routes.ts but have no corresponding implementation._ diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 3483ababf23..1a6d112a3f0 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -11,6 +11,9 @@ import { import { PerpsConnectionProvider } from '../../providers/PerpsConnectionProvider'; import Routes from '../../../../../constants/navigation/Routes'; +// Mock PerpsStreamManager +jest.mock('../../providers/PerpsStreamManager'); + // Create mock functions that can be modified during tests const mockUsePerpsAccount = jest.fn(); const mockUseHasExistingPosition = jest.fn(); @@ -691,9 +694,9 @@ describe('PerpsMarketDetailsView', () => { // Trigger the refresh await refreshControl.props.onRefresh(); - // Should refresh candle data and orders data + // Should refresh candle data + // Note: Orders refresh automatically via WebSocket, no manual refresh needed expect(mockRefreshCandleData).toHaveBeenCalledTimes(1); - expect(mockRefreshOrders).toHaveBeenCalledTimes(1); // Should not refresh position data when orders tab is active expect(mockRefreshPosition).not.toHaveBeenCalled(); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index e8cef238b6b..6c477d81fdf 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -59,10 +59,10 @@ import { capitalize } from '../../../../../util/general'; import { usePerpsAccount, usePerpsConnection, - usePerpsOpenOrders, usePerpsPerformance, usePerpsTrading, } from '../../hooks'; +import { usePerpsLiveOrders } from '../../hooks/stream'; import PerpsMarketTabs from '../../components/PerpsMarketTabs/PerpsMarketTabs'; import PerpsNotificationTooltip from '../../components/PerpsNotificationTooltip'; import { isNotificationsFeatureEnabled } from '../../../../../util/notifications'; @@ -108,15 +108,11 @@ const PerpsMarketDetailsView: React.FC = () => { const account = usePerpsAccount(); - const { isConnected } = usePerpsConnection(); + usePerpsConnection(); const { depositWithConfirmation } = usePerpsTrading(); - // Get currently open orders for this market - const { orders: ordersData, refresh: refreshOrders } = usePerpsOpenOrders({ - skipInitialFetch: !isConnected, - enablePolling: true, - pollingInterval: 5000, // Poll every 5 seconds for real-time updates - }); + // Get real-time open orders via WebSocket + const ordersData = usePerpsLiveOrders(); // Instant updates (no debouncing) // Filter orders for the current market const openOrders = useMemo(() => { @@ -133,7 +129,7 @@ const PerpsMarketDetailsView: React.FC = () => { const marketStats = usePerpsMarketStats(market?.symbol || ''); // Get candlestick data - const { candleData, isLoadingHistory, priceData, refreshCandleData } = + const { candleData, isLoadingHistory, refreshCandleData } = usePerpsPositionData({ coin: market?.symbol || '', selectedDuration, // Time duration (1hr, 1D, 1W, etc.) @@ -228,8 +224,7 @@ const PerpsMarketDetailsView: React.FC = () => { break; case 'orders': - // Refresh orders data - await refreshOrders(); + // Orders update automatically via WebSocket, no refresh needed break; case 'statistics': @@ -251,7 +246,6 @@ const PerpsMarketDetailsView: React.FC = () => { }, [ activeTabId, refreshPosition, - refreshOrders, marketStats, candleData, refreshCandleData, @@ -328,8 +322,6 @@ const PerpsMarketDetailsView: React.FC = () => { @@ -383,7 +375,8 @@ const PerpsMarketDetailsView: React.FC = () => { unfilledOrders={openOrders} onPositionUpdate={refreshPosition} onActiveTabChange={setActiveTabId} - priceData={priceData} + nextFundingTime={market?.nextFundingTime} + fundingIntervalHours={market?.fundingIntervalHours} /> @@ -471,4 +464,18 @@ const PerpsMarketDetailsView: React.FC = () => { ); }; +// Enable Why Did You Render in development +// Uncomment to enable WDYR for debugging re-renders +// if (__DEV__) { +// // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +// const { shouldEnableWhyDidYouRender } = require('../../../../../../wdyr'); +// if (shouldEnableWhyDidYouRender()) { +// // @ts-expect-error - whyDidYouRender is added by the WDYR library +// PerpsMarketDetailsView.whyDidYouRender = { +// logOnDifferentValues: true, +// customName: 'PerpsMarketDetailsView', +// }; +// } +// } + export default PerpsMarketDetailsView; diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 6c868d41d12..3bcc3078948 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -82,7 +82,7 @@ import { usePerpsOrderValidation, usePerpsPerformance, } from '../../hooks'; -import { useLivePrices } from '../../hooks/stream'; +import { usePerpsLivePrices } from '../../hooks/stream'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { usePerpsScreenTracking } from '../../hooks/usePerpsScreenTracking'; import { formatPrice } from '../../utils/formatUtils'; @@ -314,9 +314,9 @@ const PerpsOrderViewContentBase: React.FC = () => { // Get real-time price data using new stream architecture // Uses single WebSocket subscription with component-level debouncing - const prices = useLivePrices({ + const prices = usePerpsLivePrices({ symbols: [orderForm.asset], - debounceMs: 10000, // 10 seconds for testing the architecture + throttleMs: 10000, // 10 seconds for testing the architecture }); const currentPrice = prices[orderForm.asset]; diff --git a/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.test.tsx b/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.test.tsx index a840ee50300..3c53ce66101 100644 --- a/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.test.tsx @@ -10,9 +10,9 @@ import PerpsPositionsView from './PerpsPositionsView'; import { usePerpsAccount, usePerpsTrading, - usePerpsPositions, usePerpsTPSLUpdate, usePerpsClosePosition, + usePerpsLivePositions, } from '../../hooks'; import type { Position } from '../../controllers/types'; @@ -29,6 +29,9 @@ jest.mock('@react-navigation/native', () => ({ useFocusEffect: jest.fn(), })); +// Mock PerpsStreamManager +jest.mock('../../providers/PerpsStreamManager'); + jest.mock('../../hooks', () => ({ usePerpsAccount: jest.fn(), usePerpsTrading: jest.fn(), @@ -36,7 +39,6 @@ jest.mock('../../hooks', () => ({ handleUpdateTPSL: jest.fn(), isUpdating: false, })), - usePerpsPositions: jest.fn(), usePerpsClosePosition: jest.fn(() => ({ handleClosePosition: jest.fn(), isClosing: false, @@ -46,8 +48,11 @@ jest.mock('../../hooks', () => ({ { name: 'ETH', symbol: 'ETH' }, { name: 'BTC', symbol: 'BTC' }, ], - error: null, - isLoading: false, + isInitialLoading: false, + })), + usePerpsLivePositions: jest.fn(() => ({ + positions: [], + isInitialLoading: false, })), })); @@ -156,12 +161,9 @@ describe('PerpsPositionsView', () => { }); // Mock usePerpsPositions hook - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: mockPositions, - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); // Using real implementations of utility functions (calculateTotalPnL, formatPrice, formatPnl) to test actual behavior @@ -212,12 +214,9 @@ describe('PerpsPositionsView', () => { it('displays correct position count for single position', async () => { // Arrange - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: [mockPositions[0]], - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); // Act @@ -233,12 +232,9 @@ describe('PerpsPositionsView', () => { describe('Loading States', () => { it('displays loading state initially', () => { // Arrange - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: [], - isLoading: true, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: true, }); // Act @@ -249,54 +245,20 @@ describe('PerpsPositionsView', () => { }); }); - describe('Error States', () => { - it('displays error message when positions fail to load', async () => { - // Arrange - const errorMessage = 'Network error'; - (usePerpsPositions as jest.Mock).mockReturnValue({ - positions: [], - isLoading: false, - isRefreshing: false, - error: errorMessage, - loadPositions: jest.fn(), - }); - - // Act - render(); - - // Assert - expect(screen.getByText('Error Loading Positions')).toBeOnTheScreen(); - expect(screen.getByText(errorMessage)).toBeOnTheScreen(); - }); - - it('displays generic error message for non-Error objects', async () => { - // Arrange - (usePerpsPositions as jest.Mock).mockReturnValue({ - positions: [], - isLoading: false, - isRefreshing: false, - error: 'Failed to load positions', - loadPositions: jest.fn(), - }); - - // Act - render(); - - // Assert - expect(screen.getByText('Error Loading Positions')).toBeOnTheScreen(); - expect(screen.getByText('Failed to load positions')).toBeOnTheScreen(); - }); - }); + // Error States tests are commented out as the new usePerpsLivePositions + // hook doesn't return error state - errors are handled internally + // describe('Error States', () => { + // it('displays error message when positions fail to load', async () => { + // // Test removed - new hook doesn't expose error state + // }); + // }); describe('Empty State', () => { it('displays empty state when no positions are available', async () => { // Arrange - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); // Act @@ -316,12 +278,9 @@ describe('PerpsPositionsView', () => { it('displays empty state when positions is null', async () => { // Arrange - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); // Act @@ -430,12 +389,9 @@ describe('PerpsPositionsView', () => { { ...mockPositions[0], coin: 'ETH' }, { ...mockPositions[0], coin: 'ETH' }, ]; - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: duplicatePositions, - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); // Act @@ -465,12 +421,9 @@ describe('PerpsPositionsView', () => { isClosing: false, }); - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: mockPositions, - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: mockLoadPositions, + isInitialLoading: false, }); }); @@ -547,12 +500,9 @@ describe('PerpsPositionsView', () => { isUpdating: false, }); - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: mockPositions, - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: mockLoadPositions, + isInitialLoading: false, }); }); diff --git a/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx b/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx index ec09bd1f0e1..14db2db9050 100644 --- a/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx +++ b/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx @@ -4,7 +4,7 @@ import { type ParamListBase, } from '@react-navigation/native'; import React, { useMemo, useState } from 'react'; -import { RefreshControl, SafeAreaView, ScrollView, View } from 'react-native'; +import { SafeAreaView, ScrollView, View } from 'react-native'; import { strings } from '../../../../../../locales/i18n'; import ButtonIcon, { ButtonIconSizes, @@ -23,7 +23,7 @@ import PerpsTPSLBottomSheet from '../../components/PerpsTPSLBottomSheet'; import type { Position } from '../../controllers/types'; import { usePerpsAccount, - usePerpsPositions, + usePerpsLivePositions, usePerpsTPSLUpdate, } from '../../hooks'; import { formatPnl, formatPrice } from '../../utils/formatUtils'; @@ -32,7 +32,7 @@ import { createStyles } from './PerpsPositionsView.styles'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; const PerpsPositionsView: React.FC = () => { - const { styles, theme } = useStyles(createStyles, {}); + const { styles } = useStyles(createStyles, {}); const navigation = useNavigation>(); const cachedAccountState = usePerpsAccount(); @@ -42,16 +42,18 @@ const PerpsPositionsView: React.FC = () => { ); const [isTPSLVisible, setIsTPSLVisible] = useState(false); - const { positions, isLoading, isRefreshing, error, loadPositions } = - usePerpsPositions({ - loadOnMount: true, - refreshOnFocus: true, - }); + // Get real-time positions via WebSocket + const { positions, isInitialLoading } = usePerpsLivePositions({ + throttleMs: 1000, // Update every second + }); + + const error = null; const { handleUpdateTPSL, isUpdating } = usePerpsTPSLUpdate({ onSuccess: () => { - // Refresh positions to show updated data - loadPositions({ isRefresh: true }); + // Positions update automatically via WebSocket + setIsTPSLVisible(false); + setSelectedPosition(null); }, }); @@ -74,12 +76,8 @@ const PerpsPositionsView: React.FC = () => { navigation.goBack(); }; - const handleRefresh = () => { - loadPositions({ isRefresh: true }); - }; - const renderContent = () => { - if (isLoading) { + if (isInitialLoading) { return ( @@ -153,16 +151,7 @@ const PerpsPositionsView: React.FC = () => { - - } - > + {/* Account Summary */} diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx index fe8b8ee02ff..2b841856c7f 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx @@ -13,6 +13,9 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), })); +// Mock PerpsStreamManager +jest.mock('../../providers/PerpsStreamManager'); + // Mock PerpsConnectionProvider jest.mock('../../providers/PerpsConnectionProvider', () => ({ PerpsConnectionProvider: ({ children }: { children: React.ReactNode }) => @@ -31,7 +34,6 @@ jest.mock('../../providers/PerpsConnectionProvider', () => ({ // Mock hooks jest.mock('../../hooks', () => ({ usePerpsConnection: jest.fn(), - usePerpsPositions: jest.fn(), usePerpsTrading: jest.fn(), usePerpsFirstTimeUser: jest.fn(), usePerpsAccount: jest.fn(), @@ -46,6 +48,14 @@ jest.mock('../../hooks', () => ({ })), })); +// Mock stream hooks +jest.mock('../../hooks/stream', () => ({ + usePerpsLivePositions: jest.fn(() => ({ + positions: [], + isInitialLoading: false, + })), +})); + // Mock components jest.mock('../../components/PerpsTabControlBar', () => ({ PerpsTabControlBar: ({ @@ -150,8 +160,8 @@ describe('PerpsTabView', () => { const mockUsePerpsConnection = jest.requireMock('../../hooks').usePerpsConnection; - const mockUsePerpsPositions = - jest.requireMock('../../hooks').usePerpsPositions; + const mockUsePerpsLivePositions = + jest.requireMock('../../hooks/stream').usePerpsLivePositions; const mockUsePerpsTrading = jest.requireMock('../../hooks').usePerpsTrading; const mockUsePerpsFirstTimeUser = jest.requireMock('../../hooks').usePerpsFirstTimeUser; @@ -188,11 +198,9 @@ describe('PerpsTabView', () => { isInitialized: true, }); - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); mockUsePerpsTrading.mockReturnValue({ @@ -248,11 +256,9 @@ describe('PerpsTabView', () => { }); it('should render loading state when positions are loading', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: true, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: true, }); render(); @@ -263,11 +269,9 @@ describe('PerpsTabView', () => { }); it('should render empty state when no positions exist', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); render(); @@ -281,11 +285,9 @@ describe('PerpsTabView', () => { }); it('should render positions when they exist', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [mockPosition], - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); render(); @@ -305,11 +307,9 @@ describe('PerpsTabView', () => { { ...mockPosition, coin: 'SOL', size: '50.0' }, ]; - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions, - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); render(); @@ -420,7 +420,7 @@ describe('PerpsTabView', () => { it('should have pull-to-refresh functionality configured', async () => { const mockLoadPositions = jest.fn(); - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], isLoading: false, isRefreshing: false, @@ -510,11 +510,9 @@ describe('PerpsTabView', () => { describe('State Management', () => { it('should handle refresh state correctly', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: true, - loadPositions: jest.fn(), + isInitialLoading: false, }); render(); @@ -561,11 +559,9 @@ describe('PerpsTabView', () => { stopLossPrice: undefined, }; - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [incompletePosition], - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); expect(() => render()).not.toThrow(); @@ -573,11 +569,9 @@ describe('PerpsTabView', () => { }); it('should handle empty positions array correctly', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); render(); @@ -625,7 +619,7 @@ describe('PerpsTabView', () => { // Mock console.error to avoid noise in tests const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - mockUsePerpsPositions.mockImplementation(() => { + mockUsePerpsLivePositions.mockImplementation(() => { throw new Error('Hook error'); }); @@ -644,11 +638,9 @@ describe('PerpsTabView', () => { }); it('should render text with proper variants and colors', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [mockPosition], - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); render(); @@ -667,11 +659,9 @@ describe('PerpsTabView', () => { size: `${i + 1}.0`, })); - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: manyPositions, - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); const startTime = performance.now(); @@ -689,7 +679,7 @@ describe('PerpsTabView', () => { // Simulate rapid state changes for (let i = 0; i < 5; i++) { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: i % 2 === 0 ? [] : [mockPosition], isLoading: i % 3 === 0, isRefreshing: i % 4 === 0, @@ -710,8 +700,8 @@ describe('PerpsTabViewWithProvider', () => { // Setup mocks for wrapped component tests const mockUsePerpsConnection = jest.requireMock('../../hooks') .usePerpsConnection as jest.Mock; - const mockUsePerpsPositions = jest.requireMock('../../hooks') - .usePerpsPositions as jest.Mock; + const mockUsePerpsLivePositions = jest.requireMock('../../hooks/stream') + .usePerpsLivePositions as jest.Mock; const mockUsePerpsTrading = jest.requireMock('../../hooks') .usePerpsTrading as jest.Mock; const mockUsePerpsFirstTimeUser = jest.requireMock('../../hooks') @@ -725,11 +715,9 @@ describe('PerpsTabViewWithProvider', () => { isInitialized: true, }); - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); mockUsePerpsTrading.mockReturnValue({ diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx index 42a166d1d07..dd189d9adb7 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx @@ -1,6 +1,6 @@ import { useNavigation, type NavigationProp } from '@react-navigation/native'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Modal, RefreshControl, ScrollView, View } from 'react-native'; +import { Modal, ScrollView, View } from 'react-native'; import { strings } from '../../../../../../locales/i18n'; import BottomSheet, { BottomSheetRef, @@ -36,10 +36,10 @@ import { usePerpsConnection, usePerpsEventTracking, usePerpsFirstTimeUser, - usePerpsPositions, usePerpsTrading, usePerpsPerformance, } from '../../hooks'; +import { usePerpsLivePositions } from '../../hooks/stream'; import styleSheet from './PerpsTabView.styles'; interface PerpsTabViewProps {} @@ -58,15 +58,12 @@ const PerpsTabView: React.FC = () => { const bottomSheetRef = useRef(null); - const { - positions, - isLoading: isPositionsLoading, - isRefreshing, - loadPositions, - } = usePerpsPositions(); + // Get real-time positions via WebSocket + const { positions, isInitialLoading } = usePerpsLivePositions({ + throttleMs: 1000, // Update positions every second + }); const { isFirstTimeUser } = usePerpsFirstTimeUser(); - const isLoading = isPositionsLoading; const firstTimeUserIconSize = 48 as unknown as IconSize; // Start measuring position data load time on mount @@ -88,7 +85,7 @@ const PerpsTabView: React.FC = () => { useEffect(() => { if ( !hasTrackedHomescreen.current && - !isLoading && + !isInitialLoading && positions && cachedAccountState?.totalBalance !== undefined ) { @@ -113,17 +110,13 @@ const PerpsTabView: React.FC = () => { hasTrackedHomescreen.current = true; } }, [ - isLoading, + isInitialLoading, positions, cachedAccountState?.totalBalance, track, endMeasure, ]); - const handleRefresh = useCallback(() => { - loadPositions(); - }, [loadPositions]); - const handleManageBalancePress = useCallback(() => { setIsBottomSheetVisible(true); }, []); @@ -161,7 +154,7 @@ const PerpsTabView: React.FC = () => { }, [navigation]); const renderPositionsSection = () => { - if (isLoading) { + if (isInitialLoading) { return ( @@ -254,15 +247,7 @@ const PerpsTabView: React.FC = () => { ) : ( <> - - } - > + {renderPositionsSection()} @@ -308,12 +293,13 @@ const PerpsTabView: React.FC = () => { }; // Enable WDYR tracking in development -if (__DEV__) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (PerpsTabView as any).whyDidYouRender = { - logOnDifferentValues: true, - customName: 'PerpsTabView', - }; -} +// Uncomment to enable WDYR for debugging re-renders +// if (__DEV__) { +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// (PerpsTabView as any).whyDidYouRender = { +// logOnDifferentValues: true, +// customName: 'PerpsTabView', +// }; +// } export default PerpsTabView; diff --git a/app/components/UI/Perps/Views/PerpsView.test.tsx b/app/components/UI/Perps/Views/PerpsView.test.tsx index 115a9a92d7f..2e7e231d298 100644 --- a/app/components/UI/Perps/Views/PerpsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsView.test.tsx @@ -7,9 +7,12 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(() => ({ navigate: jest.fn() })), })); +// Mock PerpsStreamManager +jest.mock('../providers/PerpsStreamManager'); + // Mock stream hooks jest.mock('../hooks/stream', () => ({ - useLivePrices: jest.fn(() => ({ + usePerpsLivePrices: jest.fn(() => ({ 'BTC-PERP': { price: '50000', percentChange24h: '2.5' }, 'ETH-PERP': { price: '3000', percentChange24h: '-1.2' }, 'SOL-PERP': { price: '100', percentChange24h: '5.0' }, diff --git a/app/components/UI/Perps/Views/PerpsView.tsx b/app/components/UI/Perps/Views/PerpsView.tsx index 61c38c6db98..bd5abd92923 100644 --- a/app/components/UI/Perps/Views/PerpsView.tsx +++ b/app/components/UI/Perps/Views/PerpsView.tsx @@ -39,7 +39,7 @@ import { usePerpsNetworkConfig, usePerpsTrading, } from '../hooks'; -import { useLivePrices } from '../hooks/stream'; +import { usePerpsLivePrices } from '../hooks/stream'; // Import connection components import PerpsConnectionErrorView from '../components/PerpsConnectionErrorView'; @@ -173,10 +173,10 @@ const PerpsView: React.FC = () => { resetError, } = usePerpsConnection(); - // Get real-time prices for popular assets with 5s debounce for portfolio view - const priceData = useLivePrices({ + // Get real-time prices for popular assets with 5s throttle for portfolio view + const priceData = usePerpsLivePrices({ symbols: POPULAR_ASSETS, - debounceMs: 5000, + throttleMs: 5000, }); // Parse available balance to check if withdrawal should be enabled diff --git a/app/components/UI/Perps/components/FundingCountdown/FundingCountdown.test.tsx b/app/components/UI/Perps/components/FundingCountdown/FundingCountdown.test.tsx new file mode 100644 index 00000000000..3d554726e53 --- /dev/null +++ b/app/components/UI/Perps/components/FundingCountdown/FundingCountdown.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import FundingCountdown from './FundingCountdown'; +import { calculateFundingCountdown } from '../../utils/marketUtils'; + +jest.mock('../../utils/marketUtils', () => ({ + calculateFundingCountdown: jest.fn(), +})); + +describe('FundingCountdown', () => { + const mockCalculateFundingCountdown = calculateFundingCountdown as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render with market-specific funding time', () => { + const nextFundingTime = Date.now() + 3600000; // 1 hour from now + const fundingIntervalHours = 4; + + mockCalculateFundingCountdown.mockReturnValue('00:59:59'); + + const { getByText } = render( + , + ); + + expect(mockCalculateFundingCountdown).toHaveBeenCalledWith({ + nextFundingTime, + fundingIntervalHours, + }); + expect(getByText('(00:59:59)')).toBeTruthy(); + }); + + it('should render with default 8-hour intervals when no specific time provided', () => { + mockCalculateFundingCountdown.mockReturnValue('07:59:59'); + + const { getByText } = render(); + + expect(mockCalculateFundingCountdown).toHaveBeenCalledWith({ + nextFundingTime: undefined, + fundingIntervalHours: undefined, + }); + expect(getByText('(07:59:59)')).toBeTruthy(); + }); + + it('should update countdown every second', () => { + jest.useFakeTimers(); + + // Initial render returns 00:59:59 + mockCalculateFundingCountdown.mockReturnValue('00:59:59'); + + const nextFundingTime = Date.now() + 3600000; + + const { getByText } = render( + , + ); + + // Verify initial render + expect(getByText('(00:59:59)')).toBeTruthy(); + + // Update mock for next call + mockCalculateFundingCountdown.mockReturnValue('00:59:58'); + + // Fast-forward 1 second to trigger the interval + jest.advanceTimersByTime(1000); + + // The component should have called calculateFundingCountdown multiple times (initial + interval) + expect(mockCalculateFundingCountdown).toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('should pass testID when provided', () => { + mockCalculateFundingCountdown.mockReturnValue('00:59:59'); + + const { getByTestId, getByText } = render( + , + ); + + expect(getByTestId('funding-countdown-test')).toBeTruthy(); + expect(getByText('(00:59:59)')).toBeTruthy(); + }); + + it('should accept and apply style prop', () => { + mockCalculateFundingCountdown.mockReturnValue('00:59:59'); + + const customStyle = { marginLeft: 10, fontSize: 20 }; + const { getByTestId } = render( + , + ); + + const element = getByTestId('styled-countdown'); + expect(element.props.style).toEqual(expect.objectContaining(customStyle)); + }); +}); diff --git a/app/components/UI/Perps/components/FundingCountdown/FundingCountdown.tsx b/app/components/UI/Perps/components/FundingCountdown/FundingCountdown.tsx new file mode 100644 index 00000000000..625658e91cb --- /dev/null +++ b/app/components/UI/Perps/components/FundingCountdown/FundingCountdown.tsx @@ -0,0 +1,65 @@ +import React, { useState, useEffect, memo } from 'react'; +import type { TextStyle } from 'react-native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { calculateFundingCountdown } from '../../utils/marketUtils'; + +interface FundingCountdownProps { + variant?: TextVariant; + color?: TextColor; + style?: TextStyle; + testID?: string; + /** + * Next funding time in milliseconds since epoch (optional, market-specific) + */ + nextFundingTime?: number; + /** + * Funding interval in hours (optional, market-specific) + */ + fundingIntervalHours?: number; +} + +/** + * Isolated countdown component that updates every second + * without causing parent re-renders. + * Supports market-specific funding times when provided. + */ +const FundingCountdown: React.FC = ({ + variant = TextVariant.BodySM, + color = TextColor.Default, + style, + testID, + nextFundingTime, + fundingIntervalHours, +}) => { + const [countdown, setCountdown] = useState(() => + calculateFundingCountdown({ nextFundingTime, fundingIntervalHours }), + ); + + useEffect(() => { + const updateCountdown = () => { + setCountdown( + calculateFundingCountdown({ nextFundingTime, fundingIntervalHours }), + ); + }; + + // Update immediately + updateCountdown(); + + // Then update every second + const interval = setInterval(updateCountdown, 1000); + + return () => clearInterval(interval); + }, [nextFundingTime, fundingIntervalHours]); + + return ( + + ({countdown}) + + ); +}; + +// Memoize to prevent unnecessary re-renders when parent re-renders +export default memo(FundingCountdown); diff --git a/app/components/UI/Perps/components/FundingCountdown/index.ts b/app/components/UI/Perps/components/FundingCountdown/index.ts new file mode 100644 index 00000000000..aefefadb580 --- /dev/null +++ b/app/components/UI/Perps/components/FundingCountdown/index.ts @@ -0,0 +1 @@ +export { default } from './FundingCountdown'; diff --git a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceDisplay.test.tsx b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceDisplay.test.tsx new file mode 100644 index 00000000000..5a388219244 --- /dev/null +++ b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceDisplay.test.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import LivePriceDisplay from './LivePriceDisplay'; +import { usePerpsLivePrices } from '../../hooks/stream'; +import { formatPrice, formatPercentage } from '../../utils/formatUtils'; +import { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; + +// Mock dependencies +jest.mock('../../hooks/stream'); +jest.mock('../../utils/formatUtils'); + +describe('LivePriceDisplay', () => { + const mockUsePerpsLivePrices = usePerpsLivePrices as jest.MockedFunction< + typeof usePerpsLivePrices + >; + const mockFormatPrice = formatPrice as jest.MockedFunction< + typeof formatPrice + >; + const mockFormatPercentage = formatPercentage as jest.MockedFunction< + typeof formatPercentage + >; + + beforeEach(() => { + jest.clearAllMocks(); + mockFormatPrice.mockImplementation((price) => `$${price}`); + mockFormatPercentage.mockImplementation((pct) => `${pct}%`); + }); + + it('should render with live price data', () => { + mockUsePerpsLivePrices.mockReturnValue({ + BTC: { + coin: 'BTC', + price: '50000', + percentChange24h: '5.5', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('$50000')).toBeTruthy(); + expect(mockUsePerpsLivePrices).toHaveBeenCalledWith({ + symbols: ['BTC'], + throttleMs: 1000, + }); + }); + + it('should render placeholder when no price data available', () => { + mockUsePerpsLivePrices.mockReturnValue({}); + + const { getByText } = render(); + + expect(getByText('--')).toBeTruthy(); + }); + + it('should render price with change when showChange is true', () => { + mockUsePerpsLivePrices.mockReturnValue({ + ETH: { + coin: 'ETH', + price: '3000', + percentChange24h: '-2.5', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('$3000')).toBeTruthy(); + expect(getByText('-2.5%')).toBeTruthy(); + }); + + it('should render price without change when showChange is false', () => { + mockUsePerpsLivePrices.mockReturnValue({ + SOL: { + coin: 'SOL', + price: '100', + percentChange24h: '10', + timestamp: Date.now(), + }, + }); + + const { getByText, queryByText } = render( + , + ); + + expect(getByText('$100')).toBeTruthy(); + expect(queryByText('10%')).toBeNull(); + }); + + it('should use custom throttle value', () => { + mockUsePerpsLivePrices.mockReturnValue({ + DOGE: { + coin: 'DOGE', + price: '0.1', + percentChange24h: '0', + timestamp: Date.now(), + }, + }); + + render(); + + expect(mockUsePerpsLivePrices).toHaveBeenCalledWith({ + symbols: ['DOGE'], + throttleMs: 500, + }); + }); + + it('should apply custom text styles', () => { + mockUsePerpsLivePrices.mockReturnValue({ + AVAX: { + coin: 'AVAX', + price: '25', + percentChange24h: '0', + timestamp: Date.now(), + }, + }); + + const { getByTestId } = render( + , + ); + + const priceElement = getByTestId('custom-price'); + expect(priceElement).toBeTruthy(); + // Just verify the element exists with the custom testID + // Props are implementation details that shouldn't be tested + }); + + it('should handle positive price change color', () => { + mockUsePerpsLivePrices.mockReturnValue({ + UNI: { + coin: 'UNI', + price: '10', + percentChange24h: '15', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('15%')).toBeTruthy(); + }); + + it('should handle negative price change color', () => { + mockUsePerpsLivePrices.mockReturnValue({ + LINK: { + coin: 'LINK', + price: '15', + percentChange24h: '-8', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('-8%')).toBeTruthy(); + }); + + it('should handle zero price change', () => { + mockUsePerpsLivePrices.mockReturnValue({ + MATIC: { + coin: 'MATIC', + price: '1', + percentChange24h: '0', + timestamp: Date.now(), + }, + }); + + const { getByText } = render( + , + ); + + expect(getByText('0%')).toBeTruthy(); + }); + + it('should handle missing percentChange24h', () => { + mockUsePerpsLivePrices.mockReturnValue({ + DOT: { + coin: 'DOT', + price: '5', + timestamp: Date.now(), + // percentChange24h is missing + }, + }); + + const { getByText } = render(); + + expect(getByText('$5')).toBeTruthy(); + expect(getByText('0%')).toBeTruthy(); // Defaults to 0 + }); +}); diff --git a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceDisplay.tsx b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceDisplay.tsx new file mode 100644 index 00000000000..a15f2b78864 --- /dev/null +++ b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceDisplay.tsx @@ -0,0 +1,71 @@ +import React, { memo } from 'react'; +import { View } from 'react-native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { usePerpsLivePrices } from '../../hooks/stream'; +import { formatPrice, formatPercentage } from '../../utils/formatUtils'; + +interface LivePriceDisplayProps { + symbol: string; + variant?: TextVariant; + color?: TextColor; + showChange?: boolean; + testID?: string; + throttleMs?: number; +} + +/** + * Component that displays live price updates + * Subscribes to price stream independently to avoid parent re-renders + */ +const LivePriceDisplay: React.FC = ({ + symbol, + variant = TextVariant.BodyMD, + color = TextColor.Default, + showChange = false, + testID, + throttleMs = 1000, // Default to 1 second updates for price displays +}) => { + const prices = usePerpsLivePrices({ + symbols: [symbol], + throttleMs, + }); + + const priceData = prices[symbol]; + + if (!priceData) { + return ( + + -- + + ); + } + + const price = parseFloat(priceData.price); + const change = parseFloat(priceData.percentChange24h || '0'); + + if (showChange) { + const changeColor = change >= 0 ? TextColor.Success : TextColor.Error; + return ( + + + {formatPrice(price)} + + + {formatPercentage(change)} + + + ); + } + + return ( + + {formatPrice(price)} + + ); +}; + +// Memoize to prevent unnecessary re-renders when parent re-renders +export default memo(LivePriceDisplay); diff --git a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx new file mode 100644 index 00000000000..1bb3de0e06d --- /dev/null +++ b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx @@ -0,0 +1,257 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import LivePriceHeader from './LivePriceHeader'; +import { usePerpsLivePrices } from '../../hooks/stream'; +import { + formatPrice, + formatPercentage, + formatPnl, +} from '../../utils/formatUtils'; +import { useStyles } from '../../../../../component-library/hooks'; + +// Mock dependencies +jest.mock('../../hooks/stream'); +jest.mock('../../utils/formatUtils'); +jest.mock('../../../../../component-library/hooks'); + +describe('LivePriceHeader', () => { + const mockUsePerpsLivePrices = usePerpsLivePrices as jest.MockedFunction< + typeof usePerpsLivePrices + >; + const mockFormatPrice = formatPrice as jest.MockedFunction< + typeof formatPrice + >; + const mockFormatPercentage = formatPercentage as jest.MockedFunction< + typeof formatPercentage + >; + const mockFormatPnl = formatPnl as jest.MockedFunction; + const mockUseStyles = useStyles as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + mockFormatPrice.mockImplementation((price) => { + const num = typeof price === 'string' ? parseFloat(price) : price; + return `$${num.toFixed(2)}`; + }); + mockFormatPercentage.mockImplementation((pct) => `${pct}%`); + mockFormatPnl.mockImplementation((amount) => { + const num = typeof amount === 'string' ? parseFloat(amount) : amount; + return num >= 0 + ? `+$${Math.abs(num).toFixed(2)}` + : `-$${Math.abs(num).toFixed(2)}`; + }); + mockUseStyles.mockReturnValue({ + styles: { + container: { flexDirection: 'row', alignItems: 'baseline', gap: 6 }, + positionValue: { fontWeight: '700' }, + priceChange24h: { fontSize: 12 }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + theme: {} as any, + }); + }); + + it('should render with live price data', () => { + mockUsePerpsLivePrices.mockReturnValue({ + BTC: { + coin: 'BTC', + price: '50000', + percentChange24h: '5.5', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('$50000.00')).toBeTruthy(); + // 5.5% of 50000 = 2750 + expect(getByText('+$2750.00 (5.5%)')).toBeTruthy(); + }); + + it('should use fallback values when no live data', () => { + mockUsePerpsLivePrices.mockReturnValue({}); + + const { getByText } = render( + , + ); + + expect(getByText('$3000.00')).toBeTruthy(); + // 2.5% of 3000 = 75 + expect(getByText('+$75.00 (2.5%)')).toBeTruthy(); + }); + + it('should handle negative price change', () => { + mockUsePerpsLivePrices.mockReturnValue({ + SOL: { + coin: 'SOL', + price: '100', + percentChange24h: '-10', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('$100.00')).toBeTruthy(); + // -10% of 100 = -10 + expect(getByText('-$10.00 (-10%)')).toBeTruthy(); + }); + + it('should handle positive price change color', () => { + mockUsePerpsLivePrices.mockReturnValue({ + AVAX: { + coin: 'AVAX', + price: '25', + percentChange24h: '8', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('+$2.00 (8%)')).toBeTruthy(); + }); + + it('should handle zero price change', () => { + mockUsePerpsLivePrices.mockReturnValue({ + MATIC: { + coin: 'MATIC', + price: '1', + percentChange24h: '0', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('$1.00')).toBeTruthy(); + expect(getByText('+$0.00 (0%)')).toBeTruthy(); + }); + + it('should use custom throttle value', () => { + mockUsePerpsLivePrices.mockReturnValue({ + DOGE: { + coin: 'DOGE', + price: '0.1', + percentChange24h: '0', + timestamp: Date.now(), + }, + }); + + render(); + + expect(mockUsePerpsLivePrices).toHaveBeenCalledWith({ + symbols: ['DOGE'], + throttleMs: 2000, + }); + }); + + it('should use default throttle value of 1000ms', () => { + mockUsePerpsLivePrices.mockReturnValue({ + UNI: { + coin: 'UNI', + price: '10', + percentChange24h: '0', + timestamp: Date.now(), + }, + }); + + render(); + + expect(mockUsePerpsLivePrices).toHaveBeenCalledWith({ + symbols: ['UNI'], + throttleMs: 1000, + }); + }); + + it('should apply test IDs correctly', () => { + mockUsePerpsLivePrices.mockReturnValue({ + LINK: { + coin: 'LINK', + price: '15', + percentChange24h: '3', + timestamp: Date.now(), + }, + }); + + const { getByTestId } = render( + , + ); + + expect(getByTestId('price-test')).toBeTruthy(); + expect(getByTestId('change-test')).toBeTruthy(); + }); + + it('should handle missing percentChange24h', () => { + mockUsePerpsLivePrices.mockReturnValue({ + DOT: { + coin: 'DOT', + price: '5', + timestamp: Date.now(), + // percentChange24h is missing + }, + }); + + const { getByText } = render(); + + expect(getByText('$5.00')).toBeTruthy(); + expect(getByText('+$0.00 (0%)')).toBeTruthy(); // Defaults to 0 + }); + + it('should calculate change amount correctly', () => { + mockUsePerpsLivePrices.mockReturnValue({ + ADA: { + coin: 'ADA', + price: '0.5', + percentChange24h: '20', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('$0.50')).toBeTruthy(); + // 20% of 0.5 = 0.1 + expect(getByText('+$0.10 (20%)')).toBeTruthy(); + }); + + it('should use fallback values as defaults', () => { + mockUsePerpsLivePrices.mockReturnValue({}); + + const { getByText } = render( + , + ); + + expect(getByText('$0.60')).toBeTruthy(); + // -5% of 0.6 = -0.03 + expect(getByText('-$0.03 (-5%)')).toBeTruthy(); + }); + + it('should prefer live data over fallback', () => { + mockUsePerpsLivePrices.mockReturnValue({ + ALGO: { + coin: 'ALGO', + price: '0.2', + percentChange24h: '15', + timestamp: Date.now(), + }, + }); + + const { getByText } = render( + , + ); + + // Should use live data, not fallback + expect(getByText('$0.20')).toBeTruthy(); + // 15% of 0.2 = 0.03 + expect(getByText('+$0.03 (15%)')).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx new file mode 100644 index 00000000000..630f99fc17e --- /dev/null +++ b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx @@ -0,0 +1,96 @@ +import React, { memo } from 'react'; +import { View, StyleSheet } from 'react-native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { usePerpsLivePrices } from '../../hooks/stream'; +import { + formatPrice, + formatPercentage, + formatPnl, +} from '../../utils/formatUtils'; +import { useStyles } from '../../../../../component-library/hooks'; + +interface LivePriceHeaderProps { + symbol: string; + fallbackPrice?: string; + fallbackChange?: string; + testIDPrice?: string; + testIDChange?: string; + throttleMs?: number; +} + +const styleSheet = () => + StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'baseline', + gap: 6, + }, + positionValue: { + fontWeight: '700', + }, + priceChange24h: { + fontSize: 12, + }, + }); + +/** + * Component that displays live price and change for header + * Subscribes to price stream independently to avoid parent re-renders + */ +const LivePriceHeader: React.FC = ({ + symbol, + fallbackPrice = '0', + fallbackChange = '0', + testIDPrice, + testIDChange, + throttleMs = 1000, // Balanced updates for header (1 update per second) +}) => { + const { styles } = useStyles(styleSheet, {}); + const prices = usePerpsLivePrices({ + symbols: [symbol], + throttleMs, + }); + + const priceData = prices[symbol]; + + // Use fallback data if no live data yet + const displayPrice = priceData + ? parseFloat(priceData.price) + : parseFloat(fallbackPrice); + const displayChange = priceData + ? parseFloat(priceData.percentChange24h || '0') + : parseFloat(fallbackChange); + + const isPositiveChange = displayChange >= 0; + const changeColor = isPositiveChange ? TextColor.Success : TextColor.Error; + + // Calculate fiat change amount (exactly as original) + const changeAmount = (displayChange / 100) * displayPrice; + + return ( + + + {formatPrice(displayPrice)} + + + {formatPnl(changeAmount)} ({formatPercentage(displayChange.toString())}) + + + ); +}; + +// Memoize to prevent unnecessary re-renders when parent re-renders +export default memo(LivePriceHeader); diff --git a/app/components/UI/Perps/components/LivePriceDisplay/index.ts b/app/components/UI/Perps/components/LivePriceDisplay/index.ts new file mode 100644 index 00000000000..87012a2c5cd --- /dev/null +++ b/app/components/UI/Perps/components/LivePriceDisplay/index.ts @@ -0,0 +1 @@ +export { default } from './LivePriceDisplay'; diff --git a/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.test.tsx index 43b56eba93e..c1192f399d3 100644 --- a/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.test.tsx +++ b/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.test.tsx @@ -66,7 +66,7 @@ jest.mock('../PerpsSlider/PerpsSlider', () => { // Mock stream hooks jest.mock('../../hooks/stream', () => ({ - useLivePrices: jest.fn(() => ({ + usePerpsLivePrices: jest.fn(() => ({ BTC: { price: '45000' }, ETH: { price: '2500' }, })), diff --git a/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.tsx b/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.tsx index 63e04003ae2..3af0d073ec5 100644 --- a/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.tsx @@ -26,7 +26,7 @@ import { usePerpsOrderFees, usePerpsClosePositionValidation, } from '../../hooks'; -import { useLivePrices } from '../../hooks/stream'; +import { usePerpsLivePrices } from '../../hooks/stream'; import { formatPositionSize, formatPrice } from '../../utils/formatUtils'; import PerpsSlider from '../PerpsSlider/PerpsSlider'; import { createStyles } from './PerpsClosePositionBottomSheet.styles'; @@ -80,9 +80,9 @@ const PerpsClosePositionBottomSheet: React.FC< const [limitPriceInputFocused, setLimitPriceInputFocused] = useState(false); // Subscribe to real-time price with 1s debounce for position closing - const priceData = useLivePrices({ + const priceData = usePerpsLivePrices({ symbols: isVisible ? [position.coin] : [], - debounceMs: 1000, + throttleMs: 1000, }); const currentPrice = priceData[position.coin]?.price ? parseFloat(priceData[position.coin].price) diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx index adbacfa0be9..ac4ca5110e4 100644 --- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx +++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx @@ -59,7 +59,7 @@ jest.mock('../../../../../../locales/i18n', () => ({ // Mock stream hooks jest.mock('../../hooks/stream', () => ({ - useLivePrices: jest.fn(() => ({})), + usePerpsLivePrices: jest.fn(() => ({})), })); // Mock usePerpsConnection hook @@ -266,9 +266,9 @@ describe('PerpsLimitPriceBottomSheet', () => { jest.clearAllMocks(); mockUseTheme.mockReturnValue(mockTheme); - // Mock useLivePrices hook to return empty by default - const { useLivePrices } = jest.requireMock('../../hooks/stream'); - useLivePrices.mockReturnValue({}); + // Mock usePerpsLivePrices hook to return empty by default + const { usePerpsLivePrices } = jest.requireMock('../../hooks/stream'); + usePerpsLivePrices.mockReturnValue({}); // Mock usePerpsConnection hook const { usePerpsConnection } = jest.requireMock('../../hooks/index'); @@ -361,8 +361,8 @@ describe('PerpsLimitPriceBottomSheet', () => { describe('Price Data Integration', () => { it('uses real-time price data when available', () => { // Arrange - Mock returns real-time data - const { useLivePrices } = jest.requireMock('../../hooks/stream'); - useLivePrices.mockReturnValue({ + const { usePerpsLivePrices } = jest.requireMock('../../hooks/stream'); + usePerpsLivePrices.mockReturnValue({ ETH: { price: '3200.00', markPrice: '3201.00', @@ -382,8 +382,8 @@ describe('PerpsLimitPriceBottomSheet', () => { it('falls back to passed current price when real-time data unavailable', () => { // Arrange - Mock returns no real-time data - const { useLivePrices } = jest.requireMock('../../hooks/stream'); - useLivePrices.mockReturnValue({}); + const { usePerpsLivePrices } = jest.requireMock('../../hooks/stream'); + usePerpsLivePrices.mockReturnValue({}); // Act render(); @@ -395,8 +395,8 @@ describe('PerpsLimitPriceBottomSheet', () => { it('displays unavailable prices when no data', () => { // Arrange const propsWithoutPrice = { ...defaultProps, currentPrice: 0 }; - const { useLivePrices } = jest.requireMock('../../hooks/stream'); - useLivePrices.mockReturnValue({}); + const { usePerpsLivePrices } = jest.requireMock('../../hooks/stream'); + usePerpsLivePrices.mockReturnValue({}); // Act render(); @@ -407,8 +407,8 @@ describe('PerpsLimitPriceBottomSheet', () => { it('calculates default bid/ask spreads when order book data unavailable', () => { // Arrange - Mock returns only basic price data - const { useLivePrices } = jest.requireMock('../../hooks/stream'); - useLivePrices.mockReturnValue({ + const { usePerpsLivePrices } = jest.requireMock('../../hooks/stream'); + usePerpsLivePrices.mockReturnValue({ ETH: { price: '3000.00', markPrice: '3001.00', diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx index 64df6e013ae..5d701f6f0c3 100644 --- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx @@ -17,7 +17,7 @@ import { useTheme } from '../../../../../util/theme'; import Keypad from '../../../../Base/Keypad'; import { formatPrice } from '../../utils/formatUtils'; import { createStyles } from './PerpsLimitPriceBottomSheet.styles'; -import { useLivePrices } from '../../hooks/stream'; +import { usePerpsLivePrices } from '../../hooks/stream'; import { ORDER_BOOK_SPREAD } from '../../constants/hyperLiquidConfig'; interface PerpsLimitPriceBottomSheetProps { @@ -44,11 +44,11 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ // Initialize with initial limit price or empty to show placeholder const [limitPrice, setLimitPrice] = useState(initialLimitPrice || ''); - // Get real-time price data with 500ms debounce for limit price bottom sheet + // Get real-time price data with 1000ms throttle for limit price bottom sheet // Only subscribe when visible - const priceData = useLivePrices({ + const priceData = usePerpsLivePrices({ symbols: isVisible ? [asset] : [], - debounceMs: 500, + throttleMs: 1000, }); const currentPriceData = priceData[asset]; diff --git a/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.test.tsx b/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.test.tsx index 9a6f7d2d6b5..1be725d252f 100644 --- a/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.test.tsx @@ -8,6 +8,9 @@ import { PerpsMarketHeaderSelectorsIDs } from '../../../../../../e2e/selectors/P import { PerpsMarketData } from '../../controllers/types'; import ButtonIcon from '../../../../../component-library/components/Buttons/ButtonIcon'; +// Mock PerpsStreamManager +jest.mock('../../providers/PerpsStreamManager'); + const mockMarket: PerpsMarketData = { symbol: 'BTC', name: 'Bitcoin', @@ -29,8 +32,6 @@ describe('PerpsMarketHeader', () => { const { getByTestId } = renderWithProvider( , { state: initialState }, @@ -44,8 +45,6 @@ describe('PerpsMarketHeader', () => { const { UNSAFE_getByType } = renderWithProvider( , @@ -63,8 +62,6 @@ describe('PerpsMarketHeader', () => { const { UNSAFE_getByType } = renderWithProvider( , diff --git a/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx b/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx index c458525614e..9488e925374 100644 --- a/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx +++ b/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx @@ -16,20 +16,12 @@ import { useStyles } from '../../../../../component-library/hooks'; import RemoteImage from '../../../../Base/RemoteImage'; import type { PerpsMarketData } from '../../controllers/types'; import { usePerpsAssetMetadata } from '../../hooks/usePerpsAssetsMetadata'; -import { - formatPercentage, - formatPnl, - formatPrice, - parseCurrencyString, - parsePercentageString, -} from '../../utils/formatUtils'; import { styleSheet } from './PerpsMarketHeader.styles'; import { PerpsMarketHeaderSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; +import LivePriceHeader from '../LivePriceDisplay/LivePriceHeader'; interface PerpsMarketHeaderProps { market: PerpsMarketData; - currentPrice?: number; - priceChange24h?: number; onBackPress?: () => void; onMorePress?: () => void; testID?: string; @@ -37,8 +29,6 @@ interface PerpsMarketHeaderProps { const PerpsMarketHeader: React.FC = ({ market, - currentPrice, - priceChange24h, onBackPress, onMorePress, testID, @@ -46,14 +36,6 @@ const PerpsMarketHeader: React.FC = ({ const { styles } = useStyles(styleSheet, {}); const { assetUrl } = usePerpsAssetMetadata(market.symbol); - const displayPrice = currentPrice || parseCurrencyString(market.price || '0'); - const displayChange = - priceChange24h ?? parsePercentageString(market.change24hPercent); - const isPositiveChange = displayChange >= 0; - - // Calculate fiat change amount - const changeAmount = (displayChange / 100) * displayPrice; - return ( {/* Back Button */} @@ -93,21 +75,14 @@ const PerpsMarketHeader: React.FC = ({ - - {formatPrice(displayPrice)} - - - {formatPnl(changeAmount)} ( - {formatPercentage(displayChange.toString())}) - + diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx index b915eb98865..7a4bfd95e11 100644 --- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx @@ -48,7 +48,6 @@ describe('PerpsMarketStatisticsCard', () => { volume24h: '$1,234,567.89', openInterest: '$987,654.32', fundingRate: '0.0125%', - fundingCountdown: '02:15:30', currentPrice: 47500, priceChange24h: 0.05, isLoading: false, @@ -86,7 +85,6 @@ describe('PerpsMarketStatisticsCard', () => { // Check funding rate row expect(getByText('perps.market.funding_rate')).toBeOnTheScreen(); expect(getByText('0.0125%')).toBeOnTheScreen(); - expect(getByText('(02:15:30)')).toBeOnTheScreen(); }); it('displays positive funding rate in success color', () => { @@ -177,7 +175,6 @@ describe('PerpsMarketStatisticsCard', () => { expect(getByTestId('perps-statistics-volume-24h')).toBeOnTheScreen(); expect(getByTestId('perps-statistics-open-interest')).toBeOnTheScreen(); expect(getByTestId('perps-statistics-funding-rate')).toBeOnTheScreen(); - expect(getByTestId('perps-statistics-funding-countdown')).toBeOnTheScreen(); }); it('handles edge case with very small funding rate values', () => { @@ -212,15 +209,6 @@ describe('PerpsMarketStatisticsCard', () => { expect(getByText('15.7500%')).toBeOnTheScreen(); }); - it('renders funding countdown in parentheses', () => { - const { getByText } = render( - , - ); - - const countdownText = getByText('(02:15:30)'); - expect(countdownText).toBeOnTheScreen(); - }); - it('displays all market statistics with proper formatting', () => { const { getByText } = render( , @@ -232,7 +220,6 @@ describe('PerpsMarketStatisticsCard', () => { expect(getByText('$1,234,567.89')).toBeOnTheScreen(); // volume24h expect(getByText('$987,654.32')).toBeOnTheScreen(); // openInterest expect(getByText('0.0125%')).toBeOnTheScreen(); // fundingRate - expect(getByText('(02:15:30)')).toBeOnTheScreen(); // fundingCountdown }); it('calls onTooltipPress only when info icons are pressed', () => { diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx index 89adcdc5aa4..4f182cf766d 100644 --- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx +++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx @@ -13,12 +13,14 @@ import Text, { import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './PerpsMarketStatisticsCard.styles'; import type { PerpsMarketStatisticsCardProps } from './PerpsMarketStatisticsCard.types'; -// TODO: Consider renaming to PerpsMarketStatisticsCard since it isn't tied to a specific view anymore import { PerpsMarketDetailsViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; +import FundingCountdown from '../FundingCountdown'; const PerpsMarketStatisticsCard: React.FC = ({ marketStats, onTooltipPress, + nextFundingTime, + fundingIntervalHours, }) => { const { styles } = useStyles(styleSheet, {}); @@ -120,16 +122,16 @@ const PerpsMarketStatisticsCard: React.FC = ({ > {marketStats.fundingRate} - - ({marketStats.fundingCountdown}) - + /> diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts index 3a9facbfe74..ab2c38acd1e 100644 --- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts +++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts @@ -4,4 +4,12 @@ import type { PerpsTooltipContentKey } from '../PerpsBottomSheetTooltip'; export interface PerpsMarketStatisticsCardProps { marketStats: ReturnType; onTooltipPress: (contentKey: PerpsTooltipContentKey) => void; + /** + * Next funding time in milliseconds since epoch (optional, market-specific) + */ + nextFundingTime?: number; + /** + * Funding interval in hours (optional, market-specific) + */ + fundingIntervalHours?: number; } diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx index 7cb0352568c..22d237f11fb 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx @@ -107,13 +107,11 @@ jest.mock('../../../../../core/Engine', () => ({ const mockMarketStats: PerpsMarketTabsProps['marketStats'] = { currentPrice: 45000, - priceChange24h: 1125, high24h: '$46,000.00', low24h: '$44,000.00', volume24h: '$1.23B', openInterest: '$500M', fundingRate: '+0.01%', - fundingCountdown: '5h 30m', isLoading: false, refresh: jest.fn(), }; diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx index e6210247e55..00e7d166c8e 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx @@ -33,7 +33,8 @@ const PerpsMarketTabs: React.FC = ({ unfilledOrders = [], onPositionUpdate, onActiveTabChange, - priceData, + nextFundingTime, + fundingIntervalHours, }) => { const { styles } = useStyles(styleSheet, {}); const fadeAnim = useRef(new Animated.Value(0)).current; @@ -167,12 +168,20 @@ const PerpsMarketTabs: React.FC = ({ {renderTooltipModal()} ); } + const getTabTestId = (tabId: string) => { + if (tabId === 'position') return PerpsMarketTabsSelectorsIDs.POSITION_TAB; + if (tabId === 'orders') return PerpsMarketTabsSelectorsIDs.ORDERS_TAB; + return PerpsMarketTabsSelectorsIDs.STATISTICS_TAB; + }; + const renderTabBar = () => ( {tabs.map((tab) => { @@ -183,13 +192,7 @@ const PerpsMarketTabs: React.FC = ({ style={[styles.tab]} onPress={() => handleTabChange(tab.id)} activeOpacity={0.7} - testID={ - tab.id === 'position' - ? PerpsMarketTabsSelectorsIDs.POSITION_TAB - : tab.id === 'orders' - ? PerpsMarketTabsSelectorsIDs.ORDERS_TAB - : PerpsMarketTabsSelectorsIDs.STATISTICS_TAB - } + testID={getTabTestId(tab.id)} > = ({ expanded showIcon onPositionUpdate={onPositionUpdate} - priceData={priceData} /> ); @@ -233,6 +235,8 @@ const PerpsMarketTabs: React.FC = ({ ); diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.types.ts b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.types.ts index fbf18134ffa..9165823b318 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.types.ts +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.types.ts @@ -1,4 +1,4 @@ -import type { Position, Order, PriceUpdate } from '../../controllers/types'; +import type { Position, Order } from '../../controllers/types'; import { usePerpsMarketStats } from '../../hooks'; export interface TabViewProps { @@ -12,5 +12,12 @@ export interface PerpsMarketTabsProps { unfilledOrders: Order[]; onPositionUpdate?: () => Promise; onActiveTabChange?: (tabId: string) => void; - priceData?: PriceUpdate | null; + /** + * Next funding time in milliseconds since epoch (optional, market-specific) + */ + nextFundingTime?: number; + /** + * Funding interval in hours (optional, market-specific) + */ + fundingIntervalHours?: number; } diff --git a/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.tsx b/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.tsx index e3541a1776a..9d7144a2cd5 100644 --- a/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.tsx +++ b/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.tsx @@ -70,7 +70,17 @@ const PerpsOpenOrderCard: React.FC = ({ // Derive order data for display const derivedData = useMemo(() => { - const direction = order.side === 'buy' ? 'long' : 'short'; + // For reduce-only orders (TP/SL), show them as closing positions + let direction: OpenOrderCardDerivedData['direction']; + if (order.reduceOnly || order.isTrigger) { + // This is a TP/SL order closing a position + // If side is 'sell', it's closing a long position + // If side is 'buy', it's closing a short position + direction = order.side === 'sell' ? 'Close Long' : 'Close Short'; + } else { + // Regular order + direction = order.side === 'buy' ? 'long' : 'short'; + } // Calculate size in USD const sizeInUSD = BigNumber(order.originalSize) diff --git a/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.types.ts b/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.types.ts index 16e36310e87..317518716dd 100644 --- a/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.types.ts +++ b/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.types.ts @@ -10,7 +10,7 @@ export interface PerpsOpenOrderCardProps { } export interface OpenOrderCardDerivedData { - direction: 'long' | 'short'; + direction: 'long' | 'short' | 'Close Long' | 'Close Short'; sizeInUSD: string; fillPercentage: number; } diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx index eb7211091ad..4cf969939d1 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx @@ -428,91 +428,8 @@ describe('PerpsPositionCard', () => { }); describe('Hook Integration', () => { - it('calls loadPositions when usePerpsTPSLUpdate onSuccess is triggered', async () => { - // Arrange - const mockLoadPositions = jest.fn().mockResolvedValue(undefined); - const mockOnPositionUpdate = jest.fn().mockResolvedValue(undefined); - const mockHandleUpdateTPSL = jest.fn().mockResolvedValue(undefined); - - const { usePerpsPositions, usePerpsTPSLUpdate } = - jest.requireMock('../../hooks'); - - usePerpsPositions.mockReturnValue({ - loadPositions: mockLoadPositions, - }); - - // Mock implementation to capture and immediately call the onSuccess callback - usePerpsTPSLUpdate.mockImplementation( - ({ onSuccess }: { onSuccess: () => void }) => { - // Simulate the onSuccess callback being called - setTimeout(() => { - onSuccess(); - }, 0); - return { - handleUpdateTPSL: mockHandleUpdateTPSL, - isUpdating: false, - }; - }, - ); - - // Act - render( - , - ); - - // Wait for async operations to complete - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Assert - expect(mockLoadPositions).toHaveBeenCalledWith({ isRefresh: true }); - expect(mockOnPositionUpdate).toHaveBeenCalled(); - }); - - it('calls loadPositions when usePerpsClosePosition onSuccess is triggered', async () => { - // Arrange - const mockLoadPositions = jest.fn().mockResolvedValue(undefined); - const mockOnPositionUpdate = jest.fn().mockResolvedValue(undefined); - const mockHandleClosePosition = jest.fn().mockResolvedValue(undefined); - - const { usePerpsPositions, usePerpsClosePosition } = - jest.requireMock('../../hooks'); - - usePerpsPositions.mockReturnValue({ - loadPositions: mockLoadPositions, - }); - - // Mock implementation to capture and immediately call the onSuccess callback - usePerpsClosePosition.mockImplementation( - ({ onSuccess }: { onSuccess: () => void }) => { - // Simulate the onSuccess callback being called - setTimeout(() => { - onSuccess(); - }, 0); - return { - handleClosePosition: mockHandleClosePosition, - isClosing: false, - }; - }, - ); - - // Act - render( - , - ); - - // Wait for async operations to complete - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Assert - expect(mockLoadPositions).toHaveBeenCalledWith({ isRefresh: true }); - expect(mockOnPositionUpdate).toHaveBeenCalled(); - }); + // Tests removed - loadPositions no longer exists with WebSocket streaming + // Positions update automatically via WebSocket subscriptions it('returns early from handleCardPress when isLoading is true', () => { // Arrange diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx index a4daaa33644..2b9966c913c 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx @@ -36,7 +36,6 @@ import { usePerpsAssetMetadata } from '../../hooks/usePerpsAssetsMetadata'; import RemoteImage from '../../../../Base/RemoteImage'; import { usePerpsMarkets, - usePerpsPositions, usePerpsTPSLUpdate, usePerpsClosePosition, } from '../../hooks'; @@ -70,32 +69,23 @@ const PerpsPositionCard: React.FC = ({ null, ); - const { loadPositions } = usePerpsPositions({ - loadOnMount: true, - refreshOnFocus: true, - }); - const { handleUpdateTPSL, isUpdating } = usePerpsTPSLUpdate({ onSuccess: () => { - // Refresh positions to show updated data - loadPositions({ isRefresh: true }).then(() => { - // Also call parent's position update callback if provided - if (onPositionUpdate) { - onPositionUpdate(); - } - }); + // Positions update automatically via WebSocket + // Call parent's position update callback if provided + if (onPositionUpdate) { + onPositionUpdate(); + } }, }); const { handleClosePosition, isClosing } = usePerpsClosePosition({ onSuccess: () => { - // Refresh positions after successful close - loadPositions({ isRefresh: true }).then(() => { - // Also call parent's position update callback if provided - if (onPositionUpdate) { - onPositionUpdate(); - } - }); + // Positions update automatically via WebSocket + // Call parent's position update callback if provided + if (onPositionUpdate) { + onPositionUpdate(); + } setIsClosePositionVisible(false); setSelectedPosition(null); }, diff --git a/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.test.tsx index d05ef4508f5..653ca3ec379 100644 --- a/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.test.tsx +++ b/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.test.tsx @@ -57,7 +57,7 @@ jest.mock('../../hooks', () => ({ // Mock stream hooks jest.mock('../../hooks/stream', () => ({ - useLivePrices: jest.fn(() => ({})), // Return empty object for prices + usePerpsLivePrices: jest.fn(() => ({})), // Return empty object for prices })); // Mock format utilities diff --git a/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx b/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx index 5dced08d4d5..cdbcc1ad70b 100644 --- a/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx @@ -25,7 +25,7 @@ import { strings } from '../../../../../../locales/i18n'; import type { Position } from '../../controllers/types'; import { createStyles } from './PerpsTPSLBottomSheet.styles'; import { usePerpsPerformance } from '../../hooks'; -import { useLivePrices } from '../../hooks/stream'; +import { usePerpsLivePrices } from '../../hooks/stream'; import { PerpsMeasurementName } from '../../constants/performanceMetrics'; import { PerpsEventProperties, @@ -106,9 +106,9 @@ const PerpsTPSLBottomSheet: React.FC = ({ // Subscribe to real-time price only when visible and we have an asset // Use 1s debounce for TP/SL bottom sheet - const priceData = useLivePrices({ + const priceData = usePerpsLivePrices({ symbols: isVisible && asset ? [asset] : [], - debounceMs: 1000, + throttleMs: 1000, }); const livePrice = priceData[asset]?.price ? parseFloat(priceData[asset].price) diff --git a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx index 056ee2f186a..cfc1af2b8b8 100644 --- a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx +++ b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx @@ -1,20 +1,19 @@ /* eslint-disable import/no-namespace */ -import React from 'react'; import { + fireEvent, render, screen, - fireEvent, waitFor, - act, } from '@testing-library/react-native'; +import React from 'react'; import { Animated } from 'react-native'; -import PerpsTabControlBar from './PerpsTabControlBar'; -import * as PerpsHooks from '../../hooks'; import * as ComponentLibraryHooks from '../../../../../component-library/hooks'; import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; -import { Position } from '../../controllers'; +import * as PerpsHooks from '../../hooks'; +import PerpsTabControlBar from './PerpsTabControlBar'; // Mock dependencies +jest.mock('../../providers/PerpsStreamManager'); jest.mock('../../../../../component-library/hooks', () => ({ useStyles: jest.fn(() => ({ styles: { @@ -33,6 +32,13 @@ jest.mock('../../hooks', () => ({ useBalanceComparison: jest.fn(), })); +jest.mock('../../hooks/stream', () => ({ + usePerpsLivePositions: jest.fn(() => ({ + positions: [], + isInitialLoading: false, + })), +})); + jest.mock('../../utils/formatUtils', () => ({ formatPerpsFiat: jest.fn( (balance: string) => `$${parseFloat(balance || '0').toFixed(2)}`, @@ -87,7 +93,6 @@ describe('PerpsTabControlBar', () => { // Mock implementations const mockGetAccountState = jest.fn(); - const mockSubscribeToPositions = jest.fn(); const mockStartPulseAnimation = jest.fn(); const mockStopAnimation = jest.fn(); const mockCompareAndUpdateBalance = jest.fn(); @@ -125,7 +130,6 @@ describe('PerpsTabControlBar', () => { getPositions: jest.fn(), getAccountState: mockGetAccountState, subscribeToPrices: jest.fn(), - subscribeToPositions: mockSubscribeToPositions, subscribeToOrderFills: jest.fn(), deposit: jest.fn(), getDepositRoutes: jest.fn(), @@ -150,9 +154,6 @@ describe('PerpsTabControlBar', () => { // Default successful responses mockGetAccountState.mockResolvedValue(defaultAccountState); - mockSubscribeToPositions.mockReturnValue(() => { - /* empty unsubscribe function */ - }); mockCompareAndUpdateBalance.mockReturnValue('increase'); }); @@ -242,55 +243,7 @@ describe('PerpsTabControlBar', () => { }); }); - describe('WebSocket Subscription and Polling', () => { - it('subscribes to position updates on mount', async () => { - render(); - - expect(mockSubscribeToPositions).toHaveBeenCalledWith({ - callback: expect.any(Function), - }); - }); - - it('refreshes balance when position updates are received', async () => { - let positionCallback: ((positions: Position[]) => void) | null = null; - mockSubscribeToPositions.mockImplementation(({ callback }) => { - positionCallback = callback; - return () => { - /* empty unsubscribe function */ - }; - }); - - render(); - - await waitFor(() => { - expect(mockSubscribeToPositions).toHaveBeenCalled(); - }); - - // Simulate position update - await act(async () => { - positionCallback?.([ - { id: '1', symbol: 'BTC' }, - ] as unknown as Position[]); - }); - - await waitFor(() => { - expect(mockGetAccountState).toHaveBeenCalledTimes(2); // Initial + position update - }); - }); - - it('only calls getAccountState once without position updates', async () => { - render(); - - // Fast forward time to ensure no polling occurs - act(() => { - jest.advanceTimersByTime(60000); - }); - - await waitFor(() => { - expect(mockGetAccountState).toHaveBeenCalledTimes(1); // Initial load only - }); - }); - }); + // WebSocket subscription tests removed - usePerpsLivePositions handles subscriptions internally describe('Press Handler', () => { it('calls onManageBalancePress when pressed', async () => { @@ -371,16 +324,7 @@ describe('PerpsTabControlBar', () => { }); describe('Cleanup and Memory Management', () => { - it('cleans up subscription on unmount', async () => { - const mockUnsubscribe = jest.fn(); - mockSubscribeToPositions.mockReturnValue(mockUnsubscribe); - - const { unmount } = render(); - - unmount(); - - expect(mockUnsubscribe).toHaveBeenCalled(); - }); + // Subscription cleanup test removed - handled by usePerpsLivePositions internally it('stops animation on unmount', async () => { const { unmount } = render(); @@ -390,13 +334,7 @@ describe('PerpsTabControlBar', () => { expect(mockStopAnimation).toHaveBeenCalled(); }); - it('handles cleanup when subscription returns null', async () => { - mockSubscribeToPositions.mockReturnValue(null); - - const { unmount } = render(); - - expect(() => unmount()).not.toThrow(); - }); + // Null subscription test removed - no longer applicable }); describe('Edge Cases', () => { @@ -426,42 +364,7 @@ describe('PerpsTabControlBar', () => { }); }); - it('handles multiple rapid balance updates via position changes', async () => { - let positionCallback: ((positions: Position[]) => void) | undefined; - mockSubscribeToPositions.mockImplementation(({ callback }) => { - positionCallback = callback; - return jest.fn(); - }); - - render(); - - // Simulate multiple rapid position updates with different values - await act(async () => { - positionCallback?.([ - { - coin: 'BTC', - size: '1.0', - entryPrice: '50000', - unrealizedPnl: '100', - }, - ] as unknown as Position[]); - positionCallback?.([ - { - coin: 'ETH', - size: '2.0', - entryPrice: '3000', - unrealizedPnl: '200', - }, - ] as unknown as Position[]); - positionCallback?.([ - { coin: 'SOL', size: '3.0', entryPrice: '100', unrealizedPnl: '300' }, - ] as unknown as Position[]); - }); - - await waitFor(() => { - expect(mockGetAccountState).toHaveBeenCalledTimes(4); // Initial + 3 position updates - }); - }); + // Test removed - position updates handled by usePerpsLivePositions internally }); describe('Integration', () => { diff --git a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx index 02438ffff71..21f8a7debf7 100644 --- a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx +++ b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx @@ -18,6 +18,7 @@ import { useColorPulseAnimation, useBalanceComparison, } from '../../hooks'; +import { usePerpsLivePositions } from '../../hooks/stream'; import { AccountState } from '../../controllers'; import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; import { formatPerpsFiat } from '../../utils/formatUtils'; @@ -37,7 +38,7 @@ export const PerpsTabControlBar: React.FC = ({ unrealizedPnl: '', }); - const { getAccountState, subscribeToPositions } = usePerpsTrading(); + const { getAccountState } = usePerpsTrading(); // Use the reusable hooks const { startPulseAnimation, getAnimatedStyle, stopAnimation } = @@ -78,54 +79,47 @@ export const PerpsTabControlBar: React.FC = ({ // Track last positions hash to detect actual changes const lastPositionsHashRef = useRef(''); - // Auto-refresh setup with WebSocket subscription + polling fallback + // Use StreamManager for real-time position updates + const { positions } = usePerpsLivePositions({ + throttleMs: 2000, // Check every 2 seconds for balance updates + }); + + // Auto-refresh balance when positions change useEffect(() => { // Initial load getAccountBalance(); + }, [getAccountBalance]); - // Set up WebSocket subscription for real-time position updates - let unsubscribePositions: (() => void) | null = null; - - try { - unsubscribePositions = subscribeToPositions({ - callback: (positions) => { - // Create a simple hash of positions to detect actual changes - const positionsHash = JSON.stringify( - positions.map((p) => ({ - coin: p.coin, - size: p.size, - entryPrice: p.entryPrice, - unrealizedPnl: p.unrealizedPnl, - })), - ); - - // Only refresh if positions actually changed - if (positionsHash !== lastPositionsHashRef.current) { - DevLogger.log( - 'PerpsTabControlBar: Position change detected, refreshing balance', - ); - lastPositionsHashRef.current = positionsHash; - getAccountBalance(); - } - }, - }); - } catch (error) { + // Monitor position changes and refresh balance + useEffect(() => { + // Create a simple hash of positions to detect actual changes + const positionsHash = JSON.stringify( + positions.map((p) => ({ + coin: p.coin, + size: p.size, + entryPrice: p.entryPrice, + unrealizedPnl: p.unrealizedPnl, + })), + ); + + // Only refresh if positions actually changed + if ( + positionsHash !== lastPositionsHashRef.current && + lastPositionsHashRef.current !== '' + ) { DevLogger.log( - 'PerpsTabControlBar: Failed to subscribe to positions, using polling only', - error, + 'PerpsTabControlBar: Position change detected, refreshing balance', ); + lastPositionsHashRef.current = positionsHash; + getAccountBalance(); + } else if (lastPositionsHashRef.current === '') { + // First time, just store the hash + lastPositionsHashRef.current = positionsHash; } + }, [positions, getAccountBalance]); - return () => { - // Cleanup WebSocket subscription - if (unsubscribePositions) { - unsubscribePositions(); - } - - // Cleanup animations - stopAnimation(); - }; - }, [getAccountBalance, subscribeToPositions, stopAnimation]); + // Cleanup animations on unmount + useEffect(() => () => stopAnimation(), [stopAnimation]); const handlePress = () => { onManageBalancePress?.(); diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index f426b9aab0b..762f0be5253 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -53,6 +53,7 @@ import type { OrderResult, Position, SubscribeOrderFillsParams, + SubscribeOrdersParams, SubscribePositionsParams, SubscribePricesParams, SwitchProviderResult, @@ -1711,6 +1712,14 @@ export class PerpsController extends BaseController< return provider.subscribeToOrderFills(params); } + /** + * Subscribe to live order updates + */ + subscribeToOrders(params: SubscribeOrdersParams): () => void { + const provider = this.getActiveProvider(); + return provider.subscribeToOrders(params); + } + /** * Configure live data throttling */ diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts index 3020ca792fb..f383b30b3ed 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts @@ -2475,12 +2475,13 @@ describe('HyperLiquidProvider', () => { expect(result).toEqual([]); }); - it('should handle metaAndAssetCtxs call successfully', async () => { + it('should handle meta and predictedFundings calls successfully', async () => { mockClientService.getInfoClient = jest.fn().mockReturnValue({ meta: jest.fn().mockResolvedValue({ universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], }), allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + predictedFundings: jest.fn().mockResolvedValue([]), metaAndAssetCtxs: jest.fn().mockResolvedValue([ { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, [ @@ -2496,8 +2497,9 @@ describe('HyperLiquidProvider', () => { const result = await provider.getMarketDataWithPrices(); expect(Array.isArray(result)).toBe(true); + expect(mockClientService.getInfoClient().meta).toHaveBeenCalled(); expect( - mockClientService.getInfoClient().metaAndAssetCtxs, + mockClientService.getInfoClient().predictedFundings, ).toHaveBeenCalled(); }); }); diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index 8a1af42a754..d0165e15aa7 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -22,6 +22,7 @@ import { HyperLiquidWalletService } from '../../services/HyperLiquidWalletServic import { adaptAccountStateFromSDK, adaptMarketFromSDK, + adaptOrderFromSDK, adaptPositionFromSDK, buildAssetMapping, formatHyperLiquidPrice, @@ -70,6 +71,7 @@ import type { Position, ReadyToTradeResult, SubscribeOrderFillsParams, + SubscribeOrdersParams, SubscribePositionsParams, SubscribePricesParams, ToggleTestnetResult, @@ -1177,62 +1179,8 @@ export class HyperLiquidProvider implements IPerpsProvider { DevLogger.log('Currently open orders received:', rawOrders); - // Transform HyperLiquid open orders to abstract Order type - const orders: Order[] = (rawOrders || []).map((rawOrder) => { - const orderId = rawOrder.oid?.toString() || ''; - const symbol = rawOrder.coin; - const side = rawOrder.side === 'B' ? 'buy' : 'sell'; - const detailedOrderType = rawOrder.orderType || ''; - const orderType = detailedOrderType.toLowerCase().includes('limit') - ? 'limit' - : 'market'; - const size = rawOrder.sz; - const originalSize = rawOrder.origSz || size; - const price = rawOrder.limitPx || rawOrder.triggerPx || '0'; - const isTrigger = rawOrder.isTrigger || false; - const reduceOnly = rawOrder.reduceOnly || false; - - // Calculate filled and remaining size - const currentSize = parseFloat(size); - const origSize = parseFloat(originalSize); - const filledSize = origSize - currentSize; - - // Check for TP/SL in child orders - let takeProfitPrice: string | undefined; - let stopLossPrice: string | undefined; - - if (rawOrder.children && rawOrder.children.length > 0) { - rawOrder.children.forEach((child: typeof rawOrder) => { - if (child.isTrigger && child.orderType) { - if (child.orderType.includes('Take Profit')) { - takeProfitPrice = child.triggerPx || child.limitPx; - } else if (child.orderType.includes('Stop')) { - stopLossPrice = child.triggerPx || child.limitPx; - } - } - }); - } - - return { - orderId, - symbol, - side, - orderType, - size, - originalSize, - price, - filledSize: filledSize.toString(), - remainingSize: size, - status: 'open' as const, - timestamp: rawOrder.timestamp, - lastUpdated: rawOrder.timestamp, - takeProfitPrice, - stopLossPrice, - detailedOrderType, - isTrigger, - reduceOnly, - }; - }); + // Transform HyperLiquid open orders to abstract Order type using adapter + const orders: Order[] = (rawOrders || []).map(adaptOrderFromSDK); return orders; } catch (error) { @@ -1355,9 +1303,10 @@ export class HyperLiquidProvider implements IPerpsProvider { const infoClient = this.clientService.getInfoClient(); // Fetch all required data in parallel for better performance - const [perpsMeta, allMids] = await Promise.all([ + const [perpsMeta, allMids, predictedFundings] = await Promise.all([ infoClient.meta(), infoClient.allMids(), + infoClient.predictedFundings(), ]); if (!perpsMeta?.universe || !allMids) { @@ -1373,6 +1322,7 @@ export class HyperLiquidProvider implements IPerpsProvider { universe: perpsMeta.universe, assetCtxs, allMids, + predictedFundings, }); } catch (error) { DevLogger.log('Error getting market data with prices:', error); @@ -1453,6 +1403,8 @@ export class HyperLiquidProvider implements IPerpsProvider { }; } } catch (error) { + // Log the error before falling back + DevLogger.log('Failed to get max leverage for symbol', error); // If we can't get max leverage, use the default as fallback const defaultMaxLeverage = PERPS_CONSTANTS.DEFAULT_MAX_LEVERAGE; if (params.leverage < 1 || params.leverage > defaultMaxLeverage) { @@ -1810,6 +1762,13 @@ export class HyperLiquidProvider implements IPerpsProvider { return this.subscriptionService.subscribeToOrderFills(params); } + /** + * Subscribe to live order updates + */ + subscribeToOrders(params: SubscribeOrdersParams): () => void { + return this.subscriptionService.subscribeToOrders(params); + } + /** * Configure live data settings */ diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts index eb1e62108e5..3fd91002834 100644 --- a/app/components/UI/Perps/controllers/types/index.ts +++ b/app/components/UI/Perps/controllers/types/index.ts @@ -143,6 +143,14 @@ export interface PerpsMarketData { * Trading volume as formatted string (e.g., '$1.2B', '$850M') */ volume: string; + /** + * Next funding time in milliseconds since epoch (optional, market-specific) + */ + nextFundingTime?: number; + /** + * Funding interval in hours (optional, market-specific) + */ + fundingIntervalHours?: number; } export interface ToggleTestnetResult { @@ -340,6 +348,12 @@ export interface SubscribeOrderFillsParams { since?: number; // Future: only fills after timestamp } +export interface SubscribeOrdersParams { + callback: (orders: Order[]) => void; + accountId?: CaipAccountId; // Optional: defaults to selected account + includeHistory?: boolean; // Optional: include filled/canceled orders +} + export interface LiquidationPriceParams { entryPrice: number; leverage: number; @@ -390,7 +404,7 @@ export interface Order { remainingSize: string; // Amount remaining status: 'open' | 'filled' | 'canceled' | 'rejected' | 'triggered' | 'queued'; // Normalized status timestamp: number; // Order timestamp - lastUpdated: number; // Last status update timestamp + lastUpdated?: number; // Last status update timestamp (optional - not provided by all APIs) // TODO: Consider creating separate type for OpenOrders (UI Orders) potentially if optional properties muddy up the original Order type takeProfitPrice?: string; // Take profit price (if set) stopLossPrice?: string; // Stop loss price (if set) @@ -479,6 +493,7 @@ export interface IPerpsProvider { subscribeToPrices(params: SubscribePricesParams): () => void; subscribeToPositions(params: SubscribePositionsParams): () => void; subscribeToOrderFills(params: SubscribeOrderFillsParams): () => void; + subscribeToOrders(params: SubscribeOrdersParams): () => void; // Live data configuration setLiveDataConfig(config: Partial): void; diff --git a/app/components/UI/Perps/hooks/index.ts b/app/components/UI/Perps/hooks/index.ts index 8b3113ce86f..5ef361a145d 100644 --- a/app/components/UI/Perps/hooks/index.ts +++ b/app/components/UI/Perps/hooks/index.ts @@ -34,7 +34,6 @@ export { usePerpsPaymentTokens } from './usePerpsPaymentTokens'; // UI utility hooks export { useBalanceComparison } from './useBalanceComparison'; export { useColorPulseAnimation } from './useColorPulseAnimation'; -export { usePerpsPositions } from './usePerpsPositions'; export { usePerpsTPSLUpdate } from './usePerpsTPSLUpdate'; export { usePerpsClosePosition } from './usePerpsClosePosition'; export { usePerpsOrderFees, formatFeeRate } from './usePerpsOrderFees'; @@ -49,7 +48,6 @@ export { usePerpsFirstTimeUser } from './usePerpsFirstTimeUser'; // Transaction data hooks export { usePerpsOrderFills } from './usePerpsOrderFills'; export { usePerpsOrders } from './usePerpsOrders'; -export { usePerpsOpenOrders } from './usePerpsOpenOrders'; export { usePerpsFunding } from './usePerpsFunding'; // Event tracking hook diff --git a/app/components/UI/Perps/hooks/stream/index.ts b/app/components/UI/Perps/hooks/stream/index.ts index b7ca3f7b3a3..5241e8f8530 100644 --- a/app/components/UI/Perps/hooks/stream/index.ts +++ b/app/components/UI/Perps/hooks/stream/index.ts @@ -1,177 +1,19 @@ -import { useEffect, useState } from 'react'; -import { usePerpsStream } from '../../providers/PerpsStreamManager'; -import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; -import type { +// Export individual hooks with proper naming convention +export { usePerpsLivePrices } from './usePerpsLivePrices'; +export { usePerpsLiveOrders } from './usePerpsLiveOrders'; +export { usePerpsLivePositions } from './usePerpsLivePositions'; +export { usePerpsLiveFills } from './usePerpsLiveFills'; + +// Export types for convenience +export type { UsePerpsLivePricesOptions } from './usePerpsLivePrices'; +export type { UsePerpsLiveOrdersOptions } from './usePerpsLiveOrders'; +export type { UsePerpsLivePositionsOptions } from './usePerpsLivePositions'; +export type { UsePerpsLiveFillsOptions } from './usePerpsLiveFills'; + +// Re-export types from controllers +export type { + PriceUpdate, Order, Position, OrderFill, - PriceUpdate, } from '../../controllers/types'; - -export interface UseLivePricesOptions { - /** Array of symbols to subscribe to */ - symbols: string[]; - /** Debounce delay in milliseconds (default: 100ms) */ - debounceMs?: number; -} - -/** - * Hook for live price updates with component-specific debouncing - * @param options - Configuration options for the hook - * @returns Record of symbol to price data - */ -export function useLivePrices( - options: UseLivePricesOptions, -): Record { - const { symbols, debounceMs = 100 } = options; - const stream = usePerpsStream(); - const [prices, setPrices] = useState>({}); - - useEffect(() => { - if (symbols.length === 0) return; - - DevLogger.log( - `useLivePrices: Subscribing to symbols with ${debounceMs}ms debounce`, - { symbols }, - ); - - const unsubscribe = stream.prices.subscribeToSymbols({ - symbols, - callback: (newPrices) => { - if (!newPrices) { - return; - } - DevLogger.log( - `useLivePrices: Received price update (${debounceMs}ms debounce)`, - { - symbols: Object.keys(newPrices), - prices: Object.entries(newPrices).map(([coin, data]) => ({ - coin, - price: data.price, - })), - }, - ); - setPrices(newPrices); - }, - debounceMs, - }); - - return () => { - DevLogger.log( - `useLivePrices: Unsubscribing from symbols (${debounceMs}ms debounce)`, - { symbols }, - ); - unsubscribe(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [stream, symbols.join(','), debounceMs]); - - return prices; -} - -export interface UseLiveOrdersOptions { - /** Debounce delay in milliseconds (default: 500ms) */ - debounceMs?: number; -} - -/** - * Hook for live order updates with component-specific debouncing - * @param options - Configuration options for the hook - * @returns Array of orders - */ -export function useLiveOrders(options: UseLiveOrdersOptions = {}): Order[] { - const { debounceMs = 500 } = options; - const stream = usePerpsStream(); - const [orders, setOrders] = useState([]); - - useEffect(() => { - const unsubscribe = stream.orders.subscribe({ - callback: (newOrders) => { - if (!newOrders) { - return; - } - setOrders(newOrders); - }, - debounceMs, - }); - - return unsubscribe; - }, [stream, debounceMs]); - - return orders; -} - -export interface UseLivePositionsOptions { - /** Debounce delay in milliseconds (default: 1000ms) */ - debounceMs?: number; -} - -/** - * Hook for live position updates with component-specific debouncing - * @param options - Configuration options for the hook - * @returns Array of positions - */ -export function useLivePositions( - options: UseLivePositionsOptions = {}, -): Position[] { - const { debounceMs = 1000 } = options; - const stream = usePerpsStream(); - const [positions, setPositions] = useState([]); - - useEffect(() => { - const unsubscribe = stream.positions.subscribe({ - callback: (newPositions) => { - if (!newPositions) { - return; - } - setPositions(newPositions); - }, - debounceMs, - }); - - return unsubscribe; - }, [stream, debounceMs]); - - return positions; -} - -export interface UseLiveFillsOptions { - /** Debounce delay in milliseconds (default: 0ms for immediate updates) */ - debounceMs?: number; -} - -/** - * Hook for live order fill updates with component-specific debouncing - * @param options - Configuration options for the hook - * @returns Array of order fills - */ -export function useLiveFills(options: UseLiveFillsOptions = {}): OrderFill[] { - const { debounceMs = 0 } = options; - const stream = usePerpsStream(); - const [fills, setFills] = useState([]); - - useEffect(() => { - const unsubscribe = stream.fills.subscribe({ - callback: (newFills) => { - if (!newFills) { - return; - } - setFills(newFills); - }, - debounceMs, - }); - - return unsubscribe; - }, [stream, debounceMs]); - - return fills; -} - -// Export types for components to use -export type { PriceUpdate } from '../../controllers/types'; - -// Future hooks to be added: -// export function useLiveFunding(debounceMs: number = 5000): Funding[] { ... } -// export function useLiveAccountState(debounceMs: number = 2000): AccountState { ... } -// export function useLiveOrderBook(symbol: string, debounceMs: number = 100): OrderBook { ... } -// export function useLiveTrades(symbol: string, debounceMs: number = 0): Trade[] { ... } diff --git a/app/components/UI/Perps/hooks/stream/useLiveFills.test.ts b/app/components/UI/Perps/hooks/stream/useLiveFills.test.ts index e24223a0cd0..cc4f147302a 100644 --- a/app/components/UI/Perps/hooks/stream/useLiveFills.test.ts +++ b/app/components/UI/Perps/hooks/stream/useLiveFills.test.ts @@ -1,6 +1,6 @@ import { renderHook, act, waitFor } from '@testing-library/react-native'; import React from 'react'; -import { useLiveFills } from './index'; +import { usePerpsLiveFills } from './index'; import type { OrderFill } from '../../controllers/types'; // Mock the stream provider @@ -16,7 +16,7 @@ jest.mock('../../providers/PerpsStreamManager', () => ({ children, })); -describe('useLiveFills', () => { +describe('usePerpsLiveFills', () => { const mockFill: OrderFill = { orderId: 'order-1', symbol: 'BTC-PERP', @@ -40,14 +40,14 @@ describe('useLiveFills', () => { }); it('should subscribe to fills on mount', () => { - const debounceMs = 2000; + const throttleMs = 2000; mockSubscribe.mockReturnValue(jest.fn()); - renderHook(() => useLiveFills({ debounceMs })); + renderHook(() => usePerpsLiveFills({ throttleMs })); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs, + throttleMs, }); }); @@ -55,7 +55,7 @@ describe('useLiveFills', () => { const mockUnsubscribe = jest.fn(); mockSubscribe.mockReturnValue(mockUnsubscribe); - const { unmount } = renderHook(() => useLiveFills()); + const { unmount } = renderHook(() => usePerpsLiveFills()); unmount(); @@ -69,7 +69,7 @@ describe('useLiveFills', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveFills()); + const { result } = renderHook(() => usePerpsLiveFills()); // Initially empty expect(result.current).toEqual([]); @@ -89,18 +89,18 @@ describe('useLiveFills', () => { }); }); - it('should use default debounce value when not provided', () => { + it('should use default throttle value when not provided', () => { mockSubscribe.mockReturnValue(jest.fn()); - renderHook(() => useLiveFills()); + renderHook(() => usePerpsLiveFills()); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 0, // Default value for fills (immediate) + throttleMs: 0, // Default value for fills (immediate) }); }); - it('should handle debounce changes', () => { + it('should handle throttle changes', () => { const mockUnsubscribe1 = jest.fn(); const mockUnsubscribe2 = jest.fn(); @@ -109,25 +109,25 @@ describe('useLiveFills', () => { .mockReturnValueOnce(mockUnsubscribe2); const { rerender } = renderHook( - ({ debounceMs }) => useLiveFills({ debounceMs }), + ({ throttleMs }) => usePerpsLiveFills({ throttleMs }), { - initialProps: { debounceMs: 2000 }, + initialProps: { throttleMs: 2000 }, }, ); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 2000, + throttleMs: 2000, }); - // Change debounce - rerender({ debounceMs: 3000 }); + // Change throttle + rerender({ throttleMs: 3000 }); - // Should resubscribe with new debounce + // Should resubscribe with new throttle expect(mockUnsubscribe1).toHaveBeenCalled(); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 3000, + throttleMs: 3000, }); }); @@ -138,7 +138,7 @@ describe('useLiveFills', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveFills()); + const { result } = renderHook(() => usePerpsLiveFills()); act(() => { capturedCallback([]); @@ -156,7 +156,7 @@ describe('useLiveFills', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveFills()); + const { result } = renderHook(() => usePerpsLiveFills()); // Send null update (should be handled gracefully) act(() => { @@ -193,7 +193,7 @@ describe('useLiveFills', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveFills()); + const { result } = renderHook(() => usePerpsLiveFills()); // First update const firstFills: OrderFill[] = [mockFill]; @@ -228,7 +228,7 @@ describe('useLiveFills', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveFills()); + const { result } = renderHook(() => usePerpsLiveFills()); const now = Date.now(); const fills: OrderFill[] = [ @@ -254,7 +254,7 @@ describe('useLiveFills', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveFills()); + const { result } = renderHook(() => usePerpsLiveFills()); const fills: OrderFill[] = [ { ...mockFill, orderId: 'order-fill-1', symbol: 'BTC-PERP' }, diff --git a/app/components/UI/Perps/hooks/stream/useLiveOrders.test.ts b/app/components/UI/Perps/hooks/stream/useLiveOrders.test.ts index 467fb8631f9..d1e38d905f9 100644 --- a/app/components/UI/Perps/hooks/stream/useLiveOrders.test.ts +++ b/app/components/UI/Perps/hooks/stream/useLiveOrders.test.ts @@ -1,6 +1,6 @@ import { renderHook, act, waitFor } from '@testing-library/react-native'; import React from 'react'; -import { useLiveOrders } from './index'; +import { usePerpsLiveOrders } from './index'; import type { Order } from '../../controllers/types'; // Mock the stream provider @@ -16,7 +16,7 @@ jest.mock('../../providers/PerpsStreamManager', () => ({ children, })); -describe('useLiveOrders', () => { +describe('usePerpsLiveOrders', () => { const mockOrder: Order = { orderId: 'order-1', symbol: 'BTC-PERP', @@ -40,14 +40,14 @@ describe('useLiveOrders', () => { }); it('should subscribe to orders on mount', () => { - const debounceMs = 2000; + const throttleMs = 2000; mockSubscribe.mockReturnValue(jest.fn()); - renderHook(() => useLiveOrders({ debounceMs })); + renderHook(() => usePerpsLiveOrders({ throttleMs })); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs, + throttleMs, }); }); @@ -55,7 +55,7 @@ describe('useLiveOrders', () => { const mockUnsubscribe = jest.fn(); mockSubscribe.mockReturnValue(mockUnsubscribe); - const { unmount } = renderHook(() => useLiveOrders()); + const { unmount } = renderHook(() => usePerpsLiveOrders()); unmount(); @@ -69,7 +69,7 @@ describe('useLiveOrders', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveOrders()); + const { result } = renderHook(() => usePerpsLiveOrders()); // Initially empty expect(result.current).toEqual([]); @@ -89,18 +89,18 @@ describe('useLiveOrders', () => { }); }); - it('should use default debounce value when not provided', () => { + it('should use default throttle value when not provided', () => { mockSubscribe.mockReturnValue(jest.fn()); - renderHook(() => useLiveOrders()); + renderHook(() => usePerpsLiveOrders()); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 500, // Default value for orders + throttleMs: 0, // Default value for orders (no throttling for instant updates) }); }); - it('should handle debounce changes', () => { + it('should handle throttle changes', () => { const mockUnsubscribe1 = jest.fn(); const mockUnsubscribe2 = jest.fn(); @@ -109,25 +109,25 @@ describe('useLiveOrders', () => { .mockReturnValueOnce(mockUnsubscribe2); const { rerender } = renderHook( - ({ debounceMs }) => useLiveOrders({ debounceMs }), + ({ throttleMs }) => usePerpsLiveOrders({ throttleMs }), { - initialProps: { debounceMs: 500 }, + initialProps: { throttleMs: 500 }, }, ); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 500, + throttleMs: 500, }); - // Change debounce - rerender({ debounceMs: 1000 }); + // Change throttle + rerender({ throttleMs: 1000 }); - // Should resubscribe with new debounce + // Should resubscribe with new throttle expect(mockUnsubscribe1).toHaveBeenCalled(); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 1000, + throttleMs: 1000, }); }); @@ -138,7 +138,7 @@ describe('useLiveOrders', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveOrders()); + const { result } = renderHook(() => usePerpsLiveOrders()); act(() => { capturedCallback([]); @@ -156,7 +156,7 @@ describe('useLiveOrders', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveOrders()); + const { result } = renderHook(() => usePerpsLiveOrders()); // Send null update (should be handled gracefully) act(() => { @@ -193,7 +193,7 @@ describe('useLiveOrders', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveOrders()); + const { result } = renderHook(() => usePerpsLiveOrders()); // First update const firstOrders: Order[] = [mockOrder]; diff --git a/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts b/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts index fde1017b004..28835539ce9 100644 --- a/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts +++ b/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts @@ -1,6 +1,6 @@ import { renderHook, act, waitFor } from '@testing-library/react-native'; import React from 'react'; -import { useLivePositions } from './index'; +import { usePerpsLivePositions } from './index'; import type { Position } from '../../controllers/types'; // Mock the stream provider @@ -16,7 +16,7 @@ jest.mock('../../providers/PerpsStreamManager', () => ({ children, })); -describe('useLivePositions', () => { +describe('usePerpsLivePositions', () => { const mockPosition: Position = { coin: 'BTC-PERP', size: '1.0', @@ -48,14 +48,14 @@ describe('useLivePositions', () => { }); it('should subscribe to positions on mount', () => { - const debounceMs = 3000; + const throttleMs = 3000; mockSubscribe.mockReturnValue(jest.fn()); - renderHook(() => useLivePositions({ debounceMs })); + renderHook(() => usePerpsLivePositions({ throttleMs })); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs, + throttleMs, }); }); @@ -63,7 +63,7 @@ describe('useLivePositions', () => { const mockUnsubscribe = jest.fn(); mockSubscribe.mockReturnValue(mockUnsubscribe); - const { unmount } = renderHook(() => useLivePositions()); + const { unmount } = renderHook(() => usePerpsLivePositions()); unmount(); @@ -77,10 +77,13 @@ describe('useLivePositions', () => { return jest.fn(); }); - const { result } = renderHook(() => useLivePositions()); + const { result } = renderHook(() => usePerpsLivePositions()); - // Initially empty - expect(result.current).toEqual([]); + // Initially empty with loading state + expect(result.current).toEqual({ + positions: [], + isInitialLoading: true, + }); // Simulate positions update const positions: Position[] = [ @@ -93,22 +96,25 @@ describe('useLivePositions', () => { }); await waitFor(() => { - expect(result.current).toEqual(positions); + expect(result.current).toEqual({ + positions, + isInitialLoading: false, + }); }); }); - it('should use default debounce value when not provided', () => { + it('should use default throttle value when not provided', () => { mockSubscribe.mockReturnValue(jest.fn()); - renderHook(() => useLivePositions()); + renderHook(() => usePerpsLivePositions()); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 1000, // Default value for positions + throttleMs: 0, // Default value for positions (no throttling for instant updates) }); }); - it('should handle debounce changes', () => { + it('should handle throttle changes', () => { const mockUnsubscribe1 = jest.fn(); const mockUnsubscribe2 = jest.fn(); @@ -117,25 +123,25 @@ describe('useLivePositions', () => { .mockReturnValueOnce(mockUnsubscribe2); const { rerender } = renderHook( - ({ debounceMs }) => useLivePositions({ debounceMs }), + ({ throttleMs }) => usePerpsLivePositions({ throttleMs }), { - initialProps: { debounceMs: 1000 }, + initialProps: { throttleMs: 0 }, }, ); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 1000, + throttleMs: 0, }); - // Change debounce - rerender({ debounceMs: 2000 }); + // Change throttle + rerender({ throttleMs: 2000 }); - // Should resubscribe with new debounce + // Should resubscribe with new throttle expect(mockUnsubscribe1).toHaveBeenCalled(); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 2000, + throttleMs: 2000, }); }); @@ -146,14 +152,17 @@ describe('useLivePositions', () => { return jest.fn(); }); - const { result } = renderHook(() => useLivePositions()); + const { result } = renderHook(() => usePerpsLivePositions()); act(() => { capturedCallback([]); }); await waitFor(() => { - expect(result.current).toEqual([]); + expect(result.current).toEqual({ + positions: [], + isInitialLoading: false, + }); }); }); @@ -164,7 +173,7 @@ describe('useLivePositions', () => { return jest.fn(); }); - const { result } = renderHook(() => useLivePositions()); + const { result } = renderHook(() => usePerpsLivePositions()); // Send null update (should be handled gracefully) act(() => { @@ -172,7 +181,10 @@ describe('useLivePositions', () => { }); // Should not crash and positions should remain empty - expect(result.current).toEqual([]); + expect(result.current).toEqual({ + positions: [], + isInitialLoading: true, + }); // Send undefined update act(() => { @@ -180,7 +192,10 @@ describe('useLivePositions', () => { }); // Should still not crash - expect(result.current).toEqual([]); + expect(result.current).toEqual({ + positions: [], + isInitialLoading: true, + }); // Send valid update to ensure it still works const validPositions: Position[] = [mockPosition]; @@ -190,7 +205,10 @@ describe('useLivePositions', () => { }); await waitFor(() => { - expect(result.current).toEqual(validPositions); + expect(result.current).toEqual({ + positions: validPositions, + isInitialLoading: false, + }); }); }); @@ -201,7 +219,7 @@ describe('useLivePositions', () => { return jest.fn(); }); - const { result } = renderHook(() => useLivePositions()); + const { result } = renderHook(() => usePerpsLivePositions()); // First update const firstPositions: Position[] = [mockPosition]; @@ -210,7 +228,10 @@ describe('useLivePositions', () => { }); await waitFor(() => { - expect(result.current).toEqual(firstPositions); + expect(result.current).toEqual({ + positions: firstPositions, + isInitialLoading: false, + }); }); // Second update with different positions @@ -224,8 +245,11 @@ describe('useLivePositions', () => { }); await waitFor(() => { - expect(result.current).toEqual(secondPositions); - expect(result.current).not.toContain(mockPosition); + expect(result.current).toEqual({ + positions: secondPositions, + isInitialLoading: false, + }); + expect(result.current.positions).not.toContain(mockPosition); }); }); @@ -236,7 +260,7 @@ describe('useLivePositions', () => { return jest.fn(); }); - const { result } = renderHook(() => useLivePositions()); + const { result } = renderHook(() => usePerpsLivePositions()); // Initial position const initialPosition: Position = { ...mockPosition }; @@ -245,7 +269,7 @@ describe('useLivePositions', () => { }); await waitFor(() => { - expect(result.current[0].unrealizedPnl).toBe('1000'); + expect(result.current.positions[0].unrealizedPnl).toBe('1000'); }); // Update with changed PnL @@ -260,8 +284,8 @@ describe('useLivePositions', () => { }); await waitFor(() => { - expect(result.current[0].unrealizedPnl).toBe('2000'); - expect(result.current[0].positionValue).toBe('52000'); + expect(result.current.positions[0].unrealizedPnl).toBe('2000'); + expect(result.current.positions[0].positionValue).toBe('52000'); }); }); }); diff --git a/app/components/UI/Perps/hooks/stream/useLivePrices.test.ts b/app/components/UI/Perps/hooks/stream/useLivePrices.test.ts index 5c2a8bca743..fed03edeee0 100644 --- a/app/components/UI/Perps/hooks/stream/useLivePrices.test.ts +++ b/app/components/UI/Perps/hooks/stream/useLivePrices.test.ts @@ -1,6 +1,6 @@ import { renderHook, act, waitFor } from '@testing-library/react-native'; import React from 'react'; -import { useLivePrices } from './index'; +import { usePerpsLivePrices } from './index'; import type { PriceUpdate } from '../../controllers/types'; // Mock the stream provider @@ -16,7 +16,7 @@ jest.mock('../../providers/PerpsStreamManager', () => ({ children, })); -describe('useLivePrices', () => { +describe('usePerpsLivePrices', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); @@ -28,16 +28,16 @@ describe('useLivePrices', () => { it('should subscribe to prices on mount', () => { const symbols = ['BTC-PERP', 'ETH-PERP']; - const debounceMs = 1000; + const throttleMs = 1000; mockSubscribeToSymbols.mockReturnValue(jest.fn()); - renderHook(() => useLivePrices({ symbols, debounceMs })); + renderHook(() => usePerpsLivePrices({ symbols, throttleMs })); expect(mockSubscribeToSymbols).toHaveBeenCalledWith({ symbols, callback: expect.any(Function), - debounceMs, + throttleMs, }); }); @@ -46,7 +46,7 @@ describe('useLivePrices', () => { mockSubscribeToSymbols.mockReturnValue(mockUnsubscribe); const { unmount } = renderHook(() => - useLivePrices({ symbols: ['BTC-PERP'] }), + usePerpsLivePrices({ symbols: ['BTC-PERP'] }), ); unmount(); @@ -63,7 +63,7 @@ describe('useLivePrices', () => { }); const { result } = renderHook(() => - useLivePrices({ symbols: ['BTC-PERP', 'ETH-PERP'] }), + usePerpsLivePrices({ symbols: ['BTC-PERP', 'ETH-PERP'] }), ); // Initially empty @@ -104,15 +104,15 @@ describe('useLivePrices', () => { }); }); - it('should use default debounce value when not provided', () => { + it('should use default throttle value when not provided', () => { mockSubscribeToSymbols.mockReturnValue(jest.fn()); - renderHook(() => useLivePrices({ symbols: ['BTC-PERP'] })); + renderHook(() => usePerpsLivePrices({ symbols: ['BTC-PERP'] })); expect(mockSubscribeToSymbols).toHaveBeenCalledWith({ symbols: ['BTC-PERP'], callback: expect.any(Function), - debounceMs: 100, // Default value + throttleMs: 1000, // Default value (1 second for balanced performance) }); }); @@ -125,7 +125,7 @@ describe('useLivePrices', () => { .mockReturnValueOnce(mockUnsubscribe2); const { rerender } = renderHook( - ({ symbols }) => useLivePrices({ symbols }), + ({ symbols }) => usePerpsLivePrices({ symbols }), { initialProps: { symbols: ['BTC-PERP'] }, }, @@ -134,7 +134,7 @@ describe('useLivePrices', () => { expect(mockSubscribeToSymbols).toHaveBeenCalledWith({ symbols: ['BTC-PERP'], callback: expect.any(Function), - debounceMs: 100, + throttleMs: 1000, }); // Change symbols @@ -145,11 +145,11 @@ describe('useLivePrices', () => { expect(mockSubscribeToSymbols).toHaveBeenCalledWith({ symbols: ['ETH-PERP', 'SOL-PERP'], callback: expect.any(Function), - debounceMs: 100, + throttleMs: 1000, }); }); - it('should handle debounce changes', () => { + it('should handle throttle changes', () => { const mockUnsubscribe1 = jest.fn(); const mockUnsubscribe2 = jest.fn(); @@ -158,34 +158,35 @@ describe('useLivePrices', () => { .mockReturnValueOnce(mockUnsubscribe2); const { rerender } = renderHook( - ({ debounceMs }) => useLivePrices({ symbols: ['BTC-PERP'], debounceMs }), + ({ throttleMs }) => + usePerpsLivePrices({ symbols: ['BTC-PERP'], throttleMs }), { - initialProps: { debounceMs: 100 }, + initialProps: { throttleMs: 1000 }, }, ); expect(mockSubscribeToSymbols).toHaveBeenCalledWith({ symbols: ['BTC-PERP'], callback: expect.any(Function), - debounceMs: 100, + throttleMs: 1000, }); - // Change debounce - rerender({ debounceMs: 500 }); + // Change throttle + rerender({ throttleMs: 500 }); - // Should resubscribe with new debounce + // Should resubscribe with new throttle expect(mockUnsubscribe1).toHaveBeenCalled(); expect(mockSubscribeToSymbols).toHaveBeenCalledWith({ symbols: ['BTC-PERP'], callback: expect.any(Function), - debounceMs: 500, + throttleMs: 500, }); }); it('should handle empty symbols array', () => { mockSubscribeToSymbols.mockReturnValue(jest.fn()); - const { result } = renderHook(() => useLivePrices({ symbols: [] })); + const { result } = renderHook(() => usePerpsLivePrices({ symbols: [] })); expect(result.current).toEqual({}); // Should not subscribe with empty array @@ -201,7 +202,7 @@ describe('useLivePrices', () => { }); const { result } = renderHook(() => - useLivePrices({ symbols: ['BTC-PERP', 'ETH-PERP', 'SOL-PERP'] }), + usePerpsLivePrices({ symbols: ['BTC-PERP', 'ETH-PERP', 'SOL-PERP'] }), ); // Add prices one by one @@ -266,7 +267,7 @@ describe('useLivePrices', () => { mockSubscribeToSymbols.mockImplementation(() => unsubscribes[index++]); const { rerender } = renderHook( - ({ symbols }) => useLivePrices({ symbols }), + ({ symbols }) => usePerpsLivePrices({ symbols }), { initialProps: { symbols: ['BTC-PERP'] }, }, @@ -295,7 +296,7 @@ describe('useLivePrices', () => { }); const { result } = renderHook(() => - useLivePrices({ symbols: ['BTC-PERP'] }), + usePerpsLivePrices({ symbols: ['BTC-PERP'] }), ); // Send null update (should be handled gracefully) @@ -339,7 +340,7 @@ describe('useLivePrices', () => { }); const { result, rerender } = renderHook( - ({ symbols }) => useLivePrices({ symbols }), + ({ symbols }) => usePerpsLivePrices({ symbols }), { initialProps: { symbols: ['BTC-PERP', 'ETH-PERP'] }, }, diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLiveFills.ts b/app/components/UI/Perps/hooks/stream/usePerpsLiveFills.ts new file mode 100644 index 00000000000..6c248255541 --- /dev/null +++ b/app/components/UI/Perps/hooks/stream/usePerpsLiveFills.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import { usePerpsStream } from '../../providers/PerpsStreamManager'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; +import type { OrderFill } from '../../controllers/types'; + +export interface UsePerpsLiveFillsOptions { + /** Throttle delay in milliseconds (default: 0ms for immediate updates) */ + throttleMs?: number; +} + +/** + * Hook for real-time order fill updates via WebSocket subscription + * Provides immediate notification of trade executions + * + * @param options - Configuration options for the hook + * @returns Array of order fills with real-time updates + */ +export function usePerpsLiveFills( + options: UsePerpsLiveFillsOptions = {}, +): OrderFill[] { + const { throttleMs = 0 } = options; + const stream = usePerpsStream(); + const [fills, setFills] = useState([]); + + useEffect(() => { + const logMessage = throttleMs + ? `usePerpsLiveFills: Subscribing with ${throttleMs}ms throttle` + : `usePerpsLiveFills: Subscribing with no throttle (instant updates)`; + DevLogger.log(logMessage); + + const unsubscribe = stream.fills.subscribe({ + callback: (newFills) => { + if (!newFills) { + return; + } + DevLogger.log('usePerpsLiveFills: Received fill update', { + count: newFills.length, + }); + setFills(newFills); + }, + throttleMs, + }); + + return () => { + DevLogger.log('usePerpsLiveFills: Unsubscribing'); + unsubscribe(); + }; + }, [stream, throttleMs]); + + return fills; +} diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.ts b/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.ts new file mode 100644 index 00000000000..d4b42a0d367 --- /dev/null +++ b/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import { usePerpsStream } from '../../providers/PerpsStreamManager'; +import type { Order } from '../../controllers/types'; + +export interface UsePerpsLiveOrdersOptions { + /** Throttle delay in milliseconds (default: 0 - no throttling for instant updates) */ + throttleMs?: number; +} + +/** + * Hook for real-time order updates via WebSocket subscription + * Replaces the old polling-based usePerpsOpenOrders hook + * + * Orders update instantly by default since they don't change frequently + * and users expect immediate feedback when placing/cancelling orders. + * + * @param options - Configuration options for the hook + * @returns Array of current orders with real-time updates + */ +export function usePerpsLiveOrders( + options: UsePerpsLiveOrdersOptions = {}, +): Order[] { + const { throttleMs = 0 } = options; // No throttling by default for instant updates + const stream = usePerpsStream(); + const [orders, setOrders] = useState([]); + + useEffect(() => { + const unsubscribe = stream.orders.subscribe({ + callback: (newOrders) => { + if (!newOrders) { + return; + } + setOrders(newOrders); + }, + throttleMs, + }); + + return () => { + unsubscribe(); + }; + }, [stream, throttleMs]); + + return orders; +} diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts b/app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts new file mode 100644 index 00000000000..c850e3fdccc --- /dev/null +++ b/app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts @@ -0,0 +1,82 @@ +import { useEffect, useState, useRef } from 'react'; +import { usePerpsStream } from '../../providers/PerpsStreamManager'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; +import type { Position } from '../../controllers/types'; + +// Stable empty array reference to prevent re-renders +const EMPTY_POSITIONS: Position[] = []; + +export interface UsePerpsLivePositionsOptions { + /** Throttle delay in milliseconds (default: 0 - no throttling for instant updates) */ + throttleMs?: number; +} + +export interface UsePerpsLivePositionsReturn { + /** Array of current positions */ + positions: Position[]; + /** Whether we're waiting for the first real WebSocket data (not cached) */ + isInitialLoading: boolean; +} + +/** + * Hook for real-time position updates via WebSocket subscription + * Replaces the old polling-based usePerpsPositions hook + * + * Positions update instantly by default since changes are important + * (TP/SL modifications, liquidations, etc.) and users need immediate feedback. + * + * @param options - Configuration options for the hook + * @returns Object containing positions array and loading state + */ +export function usePerpsLivePositions( + options: UsePerpsLivePositionsOptions = {}, +): UsePerpsLivePositionsReturn { + const { throttleMs = 0 } = options; // No throttling by default for instant updates + const stream = usePerpsStream(); + const [positions, setPositions] = useState(EMPTY_POSITIONS); + const [isInitialLoading, setIsInitialLoading] = useState(true); + const lastPositionsRef = useRef(EMPTY_POSITIONS); + const hasReceivedFirstUpdate = useRef(false); + + useEffect(() => { + const unsubscribe = stream.positions.subscribe({ + callback: (newPositions) => { + if (!newPositions) { + return; + } + + // Mark that we've received the first real WebSocket update + if (!hasReceivedFirstUpdate.current) { + DevLogger.log( + 'usePerpsLivePositions: Received first WebSocket update', + ); + hasReceivedFirstUpdate.current = true; + setIsInitialLoading(false); + } + // Only update if positions actually changed + // For empty arrays, use stable reference + if (newPositions.length === 0) { + if (lastPositionsRef.current.length === 0) { + // Already empty, don't update + return; + } + lastPositionsRef.current = EMPTY_POSITIONS; + setPositions(EMPTY_POSITIONS); + } else { + lastPositionsRef.current = newPositions; + setPositions(newPositions); + } + }, + throttleMs, + }); + + return () => { + unsubscribe(); + }; + }, [stream, throttleMs]); + + return { + positions, + isInitialLoading, + }; +} diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLivePrices.ts b/app/components/UI/Perps/hooks/stream/usePerpsLivePrices.ts new file mode 100644 index 00000000000..1907f0128e2 --- /dev/null +++ b/app/components/UI/Perps/hooks/stream/usePerpsLivePrices.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import { usePerpsStream } from '../../providers/PerpsStreamManager'; +import type { PriceUpdate } from '../../controllers/types'; + +export interface UsePerpsLivePricesOptions { + /** Array of symbols to subscribe to */ + symbols: string[]; + /** Throttle delay in milliseconds (default: 1000ms to prevent excessive re-renders) */ + throttleMs?: number; +} + +/** + * Hook for real-time price updates via WebSocket subscription + * Supports component-specific throttling for performance optimization + * + * Prices update every second by default to balance between real-time feel + * and performance. Components can override this based on their needs: + * - Charts might want slower updates (2000ms) + * - Price displays might want faster updates (500ms) + * + * @param options - Configuration options for the hook + * @returns Record of symbol to price data with real-time updates + */ +export function usePerpsLivePrices( + options: UsePerpsLivePricesOptions, +): Record { + const { symbols, throttleMs = 1000 } = options; // 1 second default for balanced performance + const stream = usePerpsStream(); + const [prices, setPrices] = useState>({}); + + useEffect(() => { + if (symbols.length === 0) return; + + const unsubscribe = stream.prices.subscribeToSymbols({ + symbols, + callback: (newPrices) => { + if (!newPrices) { + return; + } + setPrices(newPrices); + }, + throttleMs, + }); + + return () => { + unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stream, symbols.join(','), throttleMs]); + + return prices; +} diff --git a/app/components/UI/Perps/hooks/useHasExistingPosition.test.ts b/app/components/UI/Perps/hooks/useHasExistingPosition.test.ts index 96ce93acd70..dea911eea83 100644 --- a/app/components/UI/Perps/hooks/useHasExistingPosition.test.ts +++ b/app/components/UI/Perps/hooks/useHasExistingPosition.test.ts @@ -1,15 +1,16 @@ import { renderHook } from '@testing-library/react-hooks'; import { useHasExistingPosition } from './useHasExistingPosition'; -import { usePerpsPositions } from './usePerpsPositions'; +import { usePerpsLivePositions } from './stream'; import type { Position } from '../controllers/types'; -// Mock the usePerpsPositions hook -jest.mock('./usePerpsPositions'); +// Mock the usePerpsLivePositions hook +jest.mock('./stream', () => ({ + usePerpsLivePositions: jest.fn(), +})); describe('useHasExistingPosition', () => { - const mockUsePerpsPositions = usePerpsPositions as jest.MockedFunction< - typeof usePerpsPositions - >; + const mockUsePerpsLivePositions = + usePerpsLivePositions as jest.MockedFunction; const mockPositions: Position[] = [ { @@ -59,12 +60,9 @@ describe('useHasExistingPosition', () => { }); it('should return hasPosition as true when position exists for asset', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: mockPositions, - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); const { result } = renderHook(() => @@ -78,12 +76,9 @@ describe('useHasExistingPosition', () => { }); it('should return hasPosition as false when no position exists for asset', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: mockPositions, - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); const { result } = renderHook(() => @@ -96,13 +91,10 @@ describe('useHasExistingPosition', () => { expect(result.current.error).toBe(null); }); - it('should return loading state correctly', () => { - mockUsePerpsPositions.mockReturnValue({ + it('should return loading state as false (WebSocket loads from cache)', () => { + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: true, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); const { result } = renderHook(() => @@ -111,18 +103,14 @@ describe('useHasExistingPosition', () => { expect(result.current.hasPosition).toBe(false); expect(result.current.existingPosition).toBe(null); - expect(result.current.isLoading).toBe(true); + expect(result.current.isLoading).toBe(false); // Always false with WebSocket expect(result.current.error).toBe(null); }); - it('should return error state correctly', () => { - const errorMessage = 'Failed to load positions'; - mockUsePerpsPositions.mockReturnValue({ + it('should return error as null (WebSocket handles errors internally)', () => { + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - error: errorMessage, - loadPositions: jest.fn(), + isInitialLoading: false, }); const { result } = renderHook(() => @@ -132,35 +120,27 @@ describe('useHasExistingPosition', () => { expect(result.current.hasPosition).toBe(false); expect(result.current.existingPosition).toBe(null); expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(errorMessage); + expect(result.current.error).toBe(null); // Always null with WebSocket }); - it('should pass loadOnMount parameter correctly', () => { - mockUsePerpsPositions.mockReturnValue({ + it('should ignore loadOnMount parameter (WebSocket loads from cache)', () => { + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); - renderHook(() => + const { result } = renderHook(() => useHasExistingPosition({ asset: 'BTC', loadOnMount: false }), ); - expect(mockUsePerpsPositions).toHaveBeenCalledWith({ - loadOnMount: false, - refreshOnFocus: true, - }); + // loadOnMount is ignored in WebSocket implementation + expect(result.current.hasPosition).toBe(false); }); it('should handle empty positions array', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); const { result } = renderHook(() => @@ -172,33 +152,40 @@ describe('useHasExistingPosition', () => { }); it('should update when positions change', () => { - const { result, rerender } = renderHook(() => - useHasExistingPosition({ asset: 'BTC' }), - ); - // Initially no positions - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); - rerender(); + const { result, rerender } = renderHook(() => + useHasExistingPosition({ asset: 'BTC' }), + ); + expect(result.current.hasPosition).toBe(false); // Update with positions - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: mockPositions, - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); rerender(); expect(result.current.hasPosition).toBe(true); expect(result.current.existingPosition).toEqual(mockPositions[0]); }); + + it('should return a no-op refreshPosition function', async () => { + mockUsePerpsLivePositions.mockReturnValue({ + positions: mockPositions, + isInitialLoading: false, + }); + + const { result } = renderHook(() => + useHasExistingPosition({ asset: 'BTC' }), + ); + + // refreshPosition should be a no-op that returns a resolved promise + await expect(result.current.refreshPosition()).resolves.toBeUndefined(); + }); }); diff --git a/app/components/UI/Perps/hooks/useHasExistingPosition.ts b/app/components/UI/Perps/hooks/useHasExistingPosition.ts index e8aaa4c38a4..057b19208ca 100644 --- a/app/components/UI/Perps/hooks/useHasExistingPosition.ts +++ b/app/components/UI/Perps/hooks/useHasExistingPosition.ts @@ -1,5 +1,5 @@ import { useMemo, useCallback } from 'react'; -import { usePerpsPositions } from './usePerpsPositions'; +import { usePerpsLivePositions } from './stream'; import type { Position } from '../controllers/types'; interface UseHasExistingPositionParams { @@ -12,35 +12,33 @@ interface UseHasExistingPositionParams { interface UseHasExistingPositionReturn { /** Whether user has an existing position for the asset */ hasPosition: boolean; - /** Loading state */ + /** Loading state - always false since WebSocket data loads from cache */ isLoading: boolean; - /** Error state */ + /** Error state - always null for WebSocket subscriptions */ error: string | null; /** The existing position if found */ existingPosition: Position | null; - /** Function to refresh positions data */ + /** Function to refresh positions data - no-op for WebSocket */ refreshPosition: () => Promise; } /** * Hook to check if user has an existing position for a specific asset + * Uses WebSocket subscription for real-time position updates * @param params Parameters for position checking * @returns Object containing position existence info and related states */ export function useHasExistingPosition( params: UseHasExistingPositionParams, ): UseHasExistingPositionReturn { - const { asset, loadOnMount = true } = params; - - // Use the existing positions hook to get all positions - const { positions, isLoading, error, loadPositions } = usePerpsPositions({ - loadOnMount, - refreshOnFocus: true, - }); + const { asset } = params; + // loadOnMount is ignored since WebSocket subscriptions load from cache immediately + // Get real-time positions via WebSocket + const { positions } = usePerpsLivePositions(); // Check if user has an existing position for this asset const existingPosition = useMemo( - () => positions.find((position) => position.coin === asset) || null, + () => (positions || []).find((position) => position.coin === asset) || null, [positions, asset], ); @@ -49,15 +47,19 @@ export function useHasExistingPosition( [existingPosition], ); - // Wrapper function to refresh positions - const refreshPosition = useCallback(async () => { - await loadPositions({ isRefresh: true }); - }, [loadPositions]); + // No-op refresh function for compatibility + // Positions update automatically via WebSocket + const refreshPosition = useCallback( + async () => + // WebSocket positions update automatically, no manual refresh needed + Promise.resolve(), + [], + ); return { hasPosition, - isLoading, - error, + isLoading: false, // WebSocket data loads immediately from cache + error: null, // WebSocket subscriptions handle errors internally existingPosition, refreshPosition, }; diff --git a/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts b/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts index 21654e97dc9..3bd01656ddb 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts @@ -1,6 +1,6 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { usePerpsMarketStats } from './usePerpsMarketStats'; +import { renderHook } from '@testing-library/react-hooks'; import { CandlePeriod } from '../constants/chartConfig'; +import { usePerpsMarketStats } from './usePerpsMarketStats'; // Mock Engine jest.mock('../../../../core/Engine', () => ({ @@ -29,8 +29,8 @@ jest.mock('../utils/formatUtils', () => ({ }, })); -import { usePerpsPositionData } from './usePerpsPositionData'; import Engine from '../../../../core/Engine'; +import { usePerpsPositionData } from './usePerpsPositionData'; const mockedUsePerpsPositionData = jest.mocked(usePerpsPositionData); const mockSubscribeToPrices = Engine.context.PerpsController @@ -91,7 +91,6 @@ describe('usePerpsMarketStats', () => { mockedUsePerpsPositionData.mockReturnValue({ candleData: mockCandleData, - priceData: mockPriceData.BTC, isLoadingHistory: false, refreshCandleData: jest.fn(), }); @@ -99,13 +98,11 @@ describe('usePerpsMarketStats', () => { const { result } = renderHook(() => usePerpsMarketStats('BTC')); expect(result.current.currentPrice).toBe(45000); - expect(result.current.priceChange24h).toBe(2.5); expect(result.current.high24h).toBe('$46,000.00'); expect(result.current.low24h).toBe('$43,500.00'); expect(result.current.volume24h).toBe('$1.23B'); expect(result.current.openInterest).toBe('$987.65M'); expect(result.current.fundingRate).toBe('1.0000%'); - expect(result.current.fundingCountdown).toMatch(/^\d{2}:\d{2}:\d{2}$/); expect(result.current.isLoading).toBe(false); }); @@ -115,7 +112,6 @@ describe('usePerpsMarketStats', () => { mockedUsePerpsPositionData.mockReturnValue({ candleData: null, - priceData: null, isLoadingHistory: true, refreshCandleData: jest.fn(), }); @@ -126,64 +122,13 @@ describe('usePerpsMarketStats', () => { expect(result.current.currentPrice).toBe(0); }); - it('should calculate funding countdown correctly', () => { - // Set current time to 7:30:00 UTC (30 minutes before funding) - const mockDate = new Date('2024-01-01T07:30:00Z'); - jest.setSystemTime(mockDate); - - mockSubscribeToPrices.mockImplementation(({ callback }) => { - callback([mockPriceData.BTC]); - return jest.fn(); - }); - - mockedUsePerpsPositionData.mockReturnValue({ - candleData: mockCandleData, - priceData: mockPriceData.BTC, - isLoadingHistory: false, - refreshCandleData: jest.fn(), - }); - - const { result } = renderHook(() => usePerpsMarketStats('BTC')); - - expect(result.current.fundingCountdown).toBe('00:30:00'); - }); - - it('should update funding countdown every second', () => { - // Set initial time - const mockDate = new Date('2024-01-01T07:30:00Z'); - jest.setSystemTime(mockDate); - - mockSubscribeToPrices.mockImplementation(({ callback }) => { - callback([mockPriceData.BTC]); - return jest.fn(); - }); - - mockedUsePerpsPositionData.mockReturnValue({ - candleData: mockCandleData, - priceData: mockPriceData.BTC, - isLoadingHistory: false, - refreshCandleData: jest.fn(), - }); - - const { result } = renderHook(() => usePerpsMarketStats('BTC')); - - expect(result.current.fundingCountdown).toBe('00:30:00'); - - // Advance time by 1 second - act(() => { - jest.advanceTimersByTime(1000); - jest.setSystemTime(new Date('2024-01-01T07:30:01Z')); - }); - - expect(result.current.fundingCountdown).toBe('00:29:59'); - }); + // Funding countdown tests removed - handled by separate component it('should handle no market data gracefully', () => { mockSubscribeToPrices.mockImplementation(() => jest.fn()); mockedUsePerpsPositionData.mockReturnValue({ candleData: null, - priceData: null, isLoadingHistory: false, refreshCandleData: jest.fn(), }); @@ -191,13 +136,11 @@ describe('usePerpsMarketStats', () => { const { result } = renderHook(() => usePerpsMarketStats('BTC')); expect(result.current.currentPrice).toBe(0); - expect(result.current.priceChange24h).toBe(0); expect(result.current.high24h).toBe('$0.00'); expect(result.current.low24h).toBe('$0.00'); expect(result.current.volume24h).toBe('$0.00'); expect(result.current.openInterest).toBe('$0.00'); expect(result.current.fundingRate).toBe('0.0000%'); - expect(result.current.fundingCountdown).toMatch(/^\d{2}:\d{2}:\d{2}$/); }); it('should format large numbers correctly', () => { @@ -216,7 +159,6 @@ describe('usePerpsMarketStats', () => { mockedUsePerpsPositionData.mockReturnValue({ candleData: mockCandleData, - priceData: largeNumberPriceData.BTC, isLoadingHistory: false, refreshCandleData: jest.fn(), }); @@ -242,7 +184,6 @@ describe('usePerpsMarketStats', () => { mockedUsePerpsPositionData.mockReturnValue({ candleData: mockCandleData, - priceData: negativeFundingData.BTC, isLoadingHistory: false, refreshCandleData: jest.fn(), }); diff --git a/app/components/UI/Perps/hooks/usePerpsMarketStats.ts b/app/components/UI/Perps/hooks/usePerpsMarketStats.ts index 43e2d2bef84..b36348589d4 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketStats.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketStats.ts @@ -3,10 +3,7 @@ import Engine from '../../../../core/Engine'; import { usePerpsPositionData } from './usePerpsPositionData'; import type { PriceUpdate } from '../controllers/types'; import { formatPrice, formatLargeNumber } from '../utils/formatUtils'; -import { - calculateFundingCountdown, - calculate24hHighLow, -} from '../utils/marketUtils'; +import { calculate24hHighLow } from '../utils/marketUtils'; import { CandlePeriod, TimeDuration } from '../constants/chartConfig'; interface MarketStats { @@ -15,9 +12,7 @@ interface MarketStats { volume24h: string; openInterest: string; fundingRate: string; - fundingCountdown: string; - currentPrice: number; - priceChange24h: number; + currentPrice?: number; isLoading: boolean; } @@ -38,10 +33,7 @@ export const usePerpsMarketStats = ( symbol: string, ): UsePerpsMarketStatsReturn => { const [marketData, setMarketData] = useState({}); - const [fundingCountdown, setFundingCountdown] = useState('00:00:00'); - const [currentPriceData, setCurrentPriceData] = useState< - PriceUpdate | undefined - >(); + const [initialPrice, setInitialPrice] = useState(); // Get candlestick data for 24h high/low calculation const { candleData, refreshCandleData } = usePerpsPositionData({ @@ -51,6 +43,7 @@ export const usePerpsMarketStats = ( }); // Subscribe to market data updates (funding, open interest, volume) + // Note: We still subscribe to prices but only extract market metadata, not price itself useEffect(() => { if (!symbol) return; @@ -65,13 +58,27 @@ export const usePerpsMarketStats = ( callback: (updates: PriceUpdate[]) => { const update = updates.find((u) => u.coin === symbol); if (update) { - // Set both price data and market data from the same update - setCurrentPriceData(update); - setMarketData({ - funding: update.funding, - openInterest: update.openInterest, - volume24h: update.volume24h, + // Only extract market data, ignore price changes to prevent re-renders + setMarketData((prev) => { + // Check if market data actually changed + if ( + prev.funding === update.funding && + prev.openInterest === update.openInterest && + prev.volume24h === update.volume24h + ) { + return prev; // Return same reference if no change + } + return { + funding: update.funding, + openInterest: update.openInterest, + volume24h: update.volume24h, + }; }); + + // Store initial price only once for high/low calculation fallback + if (!initialPrice && update.price) { + setInitialPrice(parseFloat(update.price)); + } } }, }); @@ -87,32 +94,17 @@ export const usePerpsMarketStats = ( unsubscribe(); } }; - }, [symbol]); - - // Update funding countdown every second - useEffect(() => { - const updateCountdown = () => { - setFundingCountdown(calculateFundingCountdown()); - }; - - updateCountdown(); // Initial update - const interval = setInterval(updateCountdown, 1000); - - return () => clearInterval(interval); - }, []); + }, [symbol, initialPrice]); // Calculate all statistics const stats = useMemo(() => { - const currentPrice = parseFloat(currentPriceData?.price || '0'); - const priceChange24h = parseFloat( - currentPriceData?.percentChange24h || '0', - ); const { high, low } = calculate24hHighLow(candleData); + const fallbackPrice = initialPrice || 0; return { // 24h high/low from candlestick data, with fallback estimates - high24h: high > 0 ? formatPrice(high) : formatPrice(currentPrice), - low24h: low > 0 ? formatPrice(low) : formatPrice(currentPrice), + high24h: high > 0 ? formatPrice(high) : formatPrice(fallbackPrice), + low24h: low > 0 ? formatPrice(low) : formatPrice(fallbackPrice), volume24h: marketData.volume24h ? formatLargeNumber(marketData.volume24h) : '$0.00', @@ -122,12 +114,10 @@ export const usePerpsMarketStats = ( fundingRate: marketData.funding ? `${(marketData.funding * 100).toFixed(4)}%` : '0.0000%', - fundingCountdown, - currentPrice, - priceChange24h, - isLoading: !currentPriceData || !candleData, + currentPrice: fallbackPrice, + isLoading: !candleData, }; - }, [currentPriceData, candleData, marketData, fundingCountdown]); + }, [candleData, marketData, initialPrice]); // Refresh function to reload market data const refresh = useCallback(async () => { diff --git a/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts b/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts index 7ba878bae78..7b3ebfccaaa 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts @@ -3,7 +3,7 @@ import { waitFor } from '@testing-library/react-native'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import Engine from '../../../../core/Engine'; import { usePerpsMarkets } from './usePerpsMarkets'; -import type { PerpsMarketData } from '../controllers/types'; +import type { PerpsMarketData, IPerpsProvider } from '../controllers/types'; // Mock dependencies jest.mock('../../../../core/SDKConnect/utils/DevLogger'); @@ -30,7 +30,7 @@ jest.mock('../providers/PerpsConnectionProvider', () => ({ // Mock stream hooks jest.mock('./stream', () => ({ - useLivePrices: jest.fn(() => ({})), + usePerpsLivePrices: jest.fn(() => ({})), })); // Mock data @@ -56,7 +56,7 @@ const mockMarketData: PerpsMarketData[] = [ ]; const mockProvider = { - protocolId: 'hyperliquid', + protocolId: 'hyperliquid' as const, getMarketDataWithPrices: jest.fn(), getDepositRoutes: jest.fn(), getWithdrawalRoutes: jest.fn(), @@ -102,7 +102,12 @@ const mockProvider = { getOpenOrders: jest.fn(), getFunding: jest.fn(), getIsFirstTimeUser: jest.fn(), -} as const; + subscribeToOrders: jest.fn(), + unsubscribeFromOrders: jest.fn(), + unsubscribeFromPrices: jest.fn(), + unsubscribeFromPositions: jest.fn(), + unsubscribeFromOrderFills: jest.fn(), +}; const mockPerpsController = Engine.context.PerpsController as jest.Mocked< typeof Engine.context.PerpsController @@ -115,7 +120,9 @@ describe('usePerpsMarkets', () => { jest.useFakeTimers(); // Set up default mocks - mockPerpsController.getActiveProvider.mockReturnValue(mockProvider); + mockPerpsController.getActiveProvider.mockReturnValue( + mockProvider as IPerpsProvider, + ); mockProvider.getMarketDataWithPrices.mockResolvedValue(mockMarketData); }); diff --git a/app/components/UI/Perps/hooks/usePerpsMarkets.ts b/app/components/UI/Perps/hooks/usePerpsMarkets.ts index 78f7eef3494..0851dfc260c 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarkets.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarkets.ts @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import Engine from '../../../../core/Engine'; import type { PerpsMarketData } from '../controllers/types'; -import { useLivePrices } from './stream'; +import { usePerpsLivePrices } from './stream'; export interface UsePerpsMarketsResult { /** @@ -83,9 +83,9 @@ export const usePerpsMarkets = ( ); // Conditionally subscribe to live prices if enabled - const livePrices = useLivePrices({ + const livePrices = usePerpsLivePrices({ symbols: enableLivePrices ? marketSymbols : [], - debounceMs: livePriceDebounceMs, + throttleMs: livePriceDebounceMs, }); const fetchMarketData = useCallback( diff --git a/app/components/UI/Perps/hooks/usePerpsOpenOrders.test.ts b/app/components/UI/Perps/hooks/usePerpsOpenOrders.test.ts deleted file mode 100644 index 910d7f015dc..00000000000 --- a/app/components/UI/Perps/hooks/usePerpsOpenOrders.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { renderHook, act, waitFor } from '@testing-library/react-native'; -import { usePerpsOpenOrders } from './usePerpsOpenOrders'; -import Engine from '../../../../core/Engine'; -import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; -import type { Order, GetOrdersParams } from '../controllers/types'; - -// Mock Engine and DevLogger -jest.mock('../../../../core/Engine'); -jest.mock('../../../../core/SDKConnect/utils/DevLogger'); -jest.mock('../providers/PerpsConnectionProvider', () => ({ - usePerpsConnection: jest.fn(() => ({ - isInitialized: true, - isConnected: true, - })), -})); - -const mockEngine = Engine as jest.Mocked; -const mockDevLogger = DevLogger as jest.Mocked; - -describe('usePerpsOpenOrders', () => { - const mockOrders: Order[] = [ - { - orderId: 'order-1', - symbol: 'BTC-PERP', - side: 'buy', - originalSize: '1.0', - filledSize: '0.0', - price: '50000', - orderType: 'limit', - status: 'open', - timestamp: Date.now(), - reduceOnly: false, - } as Order, - { - orderId: 'order-2', - symbol: 'ETH-PERP', - side: 'sell', - originalSize: '10.0', - filledSize: '0.0', - price: '3000', - orderType: 'limit', - status: 'open', - timestamp: Date.now(), - reduceOnly: false, - } as Order, - ]; - - const mockParams = { - symbol: 'BTC-PERP', - limit: 100, - }; - - let mockGetOpenOrders: jest.MockedFunction< - (params?: GetOrdersParams) => Promise - >; - - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - - // Setup default mock implementations - mockGetOpenOrders = jest.fn().mockResolvedValue(mockOrders); - mockEngine.context.PerpsController = { - getOpenOrders: mockGetOpenOrders, - } as unknown as typeof mockEngine.context.PerpsController; - - mockDevLogger.log = jest.fn(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('fetches open orders on mount by default', async () => { - const { result } = renderHook(() => usePerpsOpenOrders()); - - // Initially loading - expect(result.current.isLoading).toBe(true); - expect(result.current.orders).toEqual([]); - expect(result.current.error).toBeNull(); - - await waitFor( - () => { - expect(result.current.isLoading).toBe(false); - }, - { timeout: 3000 }, - ); - - expect(result.current.orders).toEqual(mockOrders); - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).toHaveBeenCalledWith(undefined); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Perps: Fetching open orders from controller...', - ); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Perps: Successfully fetched open orders', - { - orderCount: 2, - }, - ); - }); - - it('skips initial fetch when skipInitialFetch is true', () => { - const { result } = renderHook(() => - usePerpsOpenOrders({ skipInitialFetch: true }), - ); - - expect(result.current.isLoading).toBe(false); - expect(result.current.orders).toEqual([]); - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).not.toHaveBeenCalled(); - }); - - it('passes params to getOpenOrders when provided', async () => { - const { result } = renderHook(() => - usePerpsOpenOrders({ params: mockParams }), - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).toHaveBeenCalledWith(mockParams); - }); - - it('handles successful data refresh', async () => { - const { result } = renderHook(() => usePerpsOpenOrders()); - - // Wait for initial load - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Mock new data for refresh - const newOrders = [{ ...mockOrders[0], price: '51000' }]; - mockGetOpenOrders.mockResolvedValueOnce(newOrders); - - // Trigger refresh - await act(async () => { - await result.current.refresh(); - }); - - expect(result.current.isRefreshing).toBe(false); - expect(result.current.orders).toEqual(newOrders); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Perps: Fetching open orders from controller...', - ); - }); - - it('handles refresh errors without clearing existing data', async () => { - const { result } = renderHook(() => usePerpsOpenOrders()); - - // Wait for initial load - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Mock error on refresh - const errorMessage = 'Network error'; - mockGetOpenOrders.mockRejectedValueOnce(new Error(errorMessage)); - - // Trigger refresh - await act(async () => { - await result.current.refresh(); - }); - - expect(result.current.isRefreshing).toBe(false); - expect(result.current.error).toBe(errorMessage); - // Existing data should be preserved on refresh error - expect(result.current.orders).toEqual(mockOrders); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Perps: Failed to fetch open orders', - expect.any(Error), - ); - }); - - it('clears existing data on initial fetch error', async () => { - const errorMessage = 'Initial fetch failed'; - mockGetOpenOrders.mockRejectedValueOnce(new Error(errorMessage)); - - const { result } = renderHook(() => usePerpsOpenOrders()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.error).toBe(errorMessage); - expect(result.current.orders).toEqual([]); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Perps: Failed to fetch open orders', - expect.any(Error), - ); - }); - - it('handles non-Error exceptions gracefully', async () => { - mockGetOpenOrders.mockRejectedValueOnce('String error'); - - const { result } = renderHook(() => usePerpsOpenOrders()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.error).toBe('Unknown error occurred'); - expect(result.current.orders).toEqual([]); - }); - - it('enables polling when enablePolling is true', async () => { - const pollingInterval = 1000; - const { result } = renderHook(() => - usePerpsOpenOrders({ enablePolling: true, pollingInterval }), - ); - - // Wait for initial load - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Fast-forward time to trigger polling - act(() => { - jest.advanceTimersByTime(pollingInterval); - }); - - await waitFor(() => { - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).toHaveBeenCalledTimes(2); - }); - }); - - it('uses default polling interval when not specified', async () => { - const { result } = renderHook(() => - usePerpsOpenOrders({ enablePolling: true }), - ); - - // Wait for initial load - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Fast-forward to default 30 second interval - act(() => { - jest.advanceTimersByTime(30000); - }); - - await waitFor(() => { - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).toHaveBeenCalledTimes(2); - }); - }); - - it('disables polling when enablePolling is false', async () => { - const { result } = renderHook(() => - usePerpsOpenOrders({ enablePolling: false, pollingInterval: 1000 }), - ); - - // Wait for initial load - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Fast-forward time - act(() => { - jest.advanceTimersByTime(5000); - }); - - // Should only be called once (initial fetch) - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).toHaveBeenCalledTimes(1); - }); - - it('cleans up polling interval on unmount', () => { - const { unmount } = renderHook(() => - usePerpsOpenOrders({ enablePolling: true, pollingInterval: 1000 }), - ); - - // Unmount the hook - unmount(); - - // Fast-forward time - should not trigger additional calls - act(() => { - jest.advanceTimersByTime(5000); - }); - - // Should only be called once (initial fetch) - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).toHaveBeenCalledTimes(1); - }); - - it('handles empty orders array from controller', async () => { - mockGetOpenOrders.mockResolvedValueOnce([]); - - const { result } = renderHook(() => usePerpsOpenOrders()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.orders).toEqual([]); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Perps: Successfully fetched open orders', - { - orderCount: 0, - }, - ); - }); - - it('handles null response from controller', async () => { - mockGetOpenOrders.mockResolvedValueOnce(null as unknown as Order[]); - - const { result } = renderHook(() => usePerpsOpenOrders()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.orders).toEqual([]); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Perps: Successfully fetched open orders', - { - orderCount: 0, - }, - ); - }); - - it('maintains separate loading states for initial fetch and refresh', async () => { - const { result } = renderHook(() => usePerpsOpenOrders()); - - // Initial loading state - expect(result.current.isLoading).toBe(true); - expect(result.current.isRefreshing).toBe(false); - - // Wait for initial load to complete - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Mock delay for refresh - mockGetOpenOrders.mockImplementationOnce( - () => - new Promise((resolve) => setTimeout(() => resolve(mockOrders), 100)), - ); - - // Trigger refresh - act(() => { - result.current.refresh(); - }); - - // Should show refreshing state - expect(result.current.isRefreshing).toBe(true); - expect(result.current.isLoading).toBe(false); - - // Wait for refresh to complete - await waitFor(() => { - expect(result.current.isRefreshing).toBe(false); - }); - }); - - it('updates params dependency when params change', async () => { - const { result, rerender } = renderHook( - ({ params }) => usePerpsOpenOrders({ params }), - { initialProps: { params: mockParams } }, - ); - - // Wait for initial load - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Change params - const newParams = { symbol: 'ETH-PERP', limit: 50 }; - rerender({ params: newParams }); - - // Should refetch with new params - await waitFor(() => { - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).toHaveBeenCalledWith(newParams); - }); - }); -}); diff --git a/app/components/UI/Perps/hooks/usePerpsOrders.test.ts b/app/components/UI/Perps/hooks/usePerpsOrders.test.ts index 435b446835a..7bd3ba557dc 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrders.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrders.test.ts @@ -419,120 +419,8 @@ describe('usePerpsOrders', () => { }); }); - describe('Polling functionality', () => { - it('does not poll by default', async () => { - // Arrange - const { result } = renderHook(() => usePerpsOrders()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Reset call count - jest.clearAllMocks(); - - // Act - advance time - act(() => { - jest.advanceTimersByTime(60000); // 1 minute - }); - - // Assert - should not have polled - expect(mockPerpsController.getOrders).not.toHaveBeenCalled(); - }); - - it('polls when enablePolling is true', async () => { - // Arrange - const pollingInterval = 30000; // 30 seconds - const { result } = renderHook(() => - usePerpsOrders({ enablePolling: true, pollingInterval }), - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Reset call count - jest.clearAllMocks(); - - // Act - advance time by polling interval - act(() => { - jest.advanceTimersByTime(pollingInterval); - }); - - // Wait for the polling request to complete - await waitFor(() => { - expect(mockPerpsController.getOrders).toHaveBeenCalledTimes(1); - }); - - // Act - advance time again - act(() => { - jest.advanceTimersByTime(pollingInterval); - }); - - await waitFor(() => { - expect(mockPerpsController.getOrders).toHaveBeenCalledTimes(2); - }); - }); - - it('uses custom polling interval correctly', async () => { - // Arrange - const customInterval = 15000; // 15 seconds - const { result } = renderHook(() => - usePerpsOrders({ - enablePolling: true, - pollingInterval: customInterval, - }), - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - jest.clearAllMocks(); - - // Act - advance time less than interval - act(() => { - jest.advanceTimersByTime(customInterval - 1000); - }); - - // Assert - should not poll yet - expect(mockPerpsController.getOrders).not.toHaveBeenCalled(); - - // Act - advance time to complete interval - act(() => { - jest.advanceTimersByTime(1000); - }); - - await waitFor(() => { - expect(mockPerpsController.getOrders).toHaveBeenCalledTimes(1); - }); - }); - - it('stops polling when component unmounts', async () => { - // Arrange - const pollingInterval = 10000; - const { result, unmount } = renderHook(() => - usePerpsOrders({ enablePolling: true, pollingInterval }), - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - jest.clearAllMocks(); - - // Act - unmount component - unmount(); - - // Advance time - act(() => { - jest.advanceTimersByTime(pollingInterval * 2); - }); - - // Assert - should not poll after unmount - expect(mockPerpsController.getOrders).not.toHaveBeenCalled(); - }); - }); + // Note: Polling functionality has been removed in favor of WebSocket streaming + // For real-time order updates, use usePerpsLiveOrders from stream hooks describe('Parameter changes', () => { it('refetches data when params change', async () => { diff --git a/app/components/UI/Perps/hooks/usePerpsOrders.ts b/app/components/UI/Perps/hooks/usePerpsOrders.ts index c4717398cc7..bebd0f0bd8d 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrders.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrders.ts @@ -31,16 +31,6 @@ export interface UsePerpsOrdersOptions { * Parameters to pass to getOrders */ params?: GetOrdersParams; - /** - * Enable automatic polling for live updates - * @default false - */ - enablePolling?: boolean; - /** - * Polling interval in milliseconds - * @default 30000 (30 seconds) - */ - pollingInterval?: number; /** * Skip initial data fetch on mount * @default false @@ -51,16 +41,13 @@ export interface UsePerpsOrdersOptions { /** * Custom hook to fetch and manage Perps orders from the controller * Provides loading states, error handling, and refresh functionality + * Note: This hook fetches historical order data. For real-time open orders, + * use usePerpsLiveOrders from the stream hooks. */ export const usePerpsOrders = ( options: UsePerpsOrdersOptions = {}, ): UsePerpsOrdersResult => { - const { - params, - enablePolling = false, - pollingInterval = 30000, // 30 seconds default - skipInitialFetch = false, - } = options; + const { params, skipInitialFetch = false } = options; const [orders, setOrders] = useState([]); const [isLoading, setIsLoading] = useState(!skipInitialFetch); @@ -117,17 +104,6 @@ export const usePerpsOrders = ( } }, [fetchOrders, skipInitialFetch]); - // Polling effect - useEffect(() => { - if (!enablePolling) return; - - const intervalId = setInterval(() => { - fetchOrders(true); - }, pollingInterval); - - return () => clearInterval(intervalId); - }, [enablePolling, pollingInterval, fetchOrders]); - return { orders, isLoading, diff --git a/app/components/UI/Perps/hooks/usePerpsPositionData.test.ts b/app/components/UI/Perps/hooks/usePerpsPositionData.test.ts index 02bc43d2c5d..a9545f4d7c9 100644 --- a/app/components/UI/Perps/hooks/usePerpsPositionData.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsPositionData.test.ts @@ -8,7 +8,6 @@ jest.mock('../../../../core/Engine', () => ({ context: { PerpsController: { fetchHistoricalCandles: jest.fn(), - subscribeToPrices: jest.fn(), }, }, })); @@ -16,9 +15,6 @@ jest.mock('../../../../core/Engine', () => ({ describe('usePerpsPositionData', () => { const mockFetchHistoricalCandles = Engine.context.PerpsController .fetchHistoricalCandles as jest.Mock; - const mockSubscribeToPrices = Engine.context.PerpsController - .subscribeToPrices as jest.Mock; - const mockUnsubscribe = jest.fn(); const mockCandleData = { candles: [ @@ -26,17 +22,9 @@ describe('usePerpsPositionData', () => { ], }; - const mockPriceUpdate = { - coin: 'ETH', - price: '3000.00', - change24h: 2.5, - markPrice: '3001.00', - }; - beforeEach(() => { jest.clearAllMocks(); mockFetchHistoricalCandles.mockResolvedValue(mockCandleData); - mockSubscribeToPrices.mockReturnValue(mockUnsubscribe); }); it('should fetch historical candles on mount', async () => { @@ -53,40 +41,9 @@ describe('usePerpsPositionData', () => { expect(mockFetchHistoricalCandles).toHaveBeenCalledWith('ETH', '1h', 24); }); - it('should subscribe to price updates on mount', () => { - renderHook(() => - usePerpsPositionData({ - coin: 'ETH', - selectedInterval: CandlePeriod.ONE_HOUR, - selectedDuration: TimeDuration.ONE_DAY, - }), - ); + // Price subscriptions have been removed - use usePerpsLivePrices for real-time prices - expect(mockSubscribeToPrices).toHaveBeenCalledWith({ - symbols: ['ETH'], - callback: expect.any(Function), - }); - }); - - it('should update price data when receiving updates', async () => { - const { result } = renderHook(() => - usePerpsPositionData({ - coin: 'ETH', - selectedInterval: CandlePeriod.ONE_HOUR, - selectedDuration: TimeDuration.ONE_DAY, - }), - ); - - // Get the callback that was passed to subscribeToPrices - const callback = mockSubscribeToPrices.mock.calls[0][0].callback; - - // Trigger price update - act(() => { - callback([mockPriceUpdate]); - }); - - expect(result.current.priceData).toEqual(mockPriceUpdate); - }); + // Price data updates have been moved to usePerpsLivePrices hook it('should handle loading state correctly', async () => { const { result, waitForNextUpdate } = renderHook(() => @@ -107,19 +64,7 @@ describe('usePerpsPositionData', () => { expect(result.current.candleData).toEqual(mockCandleData); }); - it('should unsubscribe on unmount', () => { - const { unmount } = renderHook(() => - usePerpsPositionData({ - coin: 'ETH', - selectedInterval: CandlePeriod.ONE_HOUR, - selectedDuration: TimeDuration.ONE_DAY, - }), - ); - - unmount(); - - expect(mockUnsubscribe).toHaveBeenCalled(); - }); + // Unsubscribe test removed - no subscriptions to clean up anymore it('should handle errors in fetching historical data', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); diff --git a/app/components/UI/Perps/hooks/usePerpsPositionData.ts b/app/components/UI/Perps/hooks/usePerpsPositionData.ts index 8a7cba4f71e..9e1beee800e 100644 --- a/app/components/UI/Perps/hooks/usePerpsPositionData.ts +++ b/app/components/UI/Perps/hooks/usePerpsPositionData.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; import Engine from '../../../../core/Engine'; -import type { PriceUpdate } from '../controllers/types'; import type { CandleData } from '../types'; import { calculateCandleCount, @@ -21,7 +20,6 @@ export const usePerpsPositionData = ({ selectedInterval, }: UsePerpsPositionDataProps) => { const [candleData, setCandleData] = useState(null); - const [priceData, setPriceData] = useState(null); const [isLoadingHistory, setIsLoadingHistory] = useState(false); const fetchHistoricalCandles = useCallback(async () => { @@ -41,26 +39,6 @@ export const usePerpsPositionData = ({ return historicalData; }, [coin, selectedDuration, selectedInterval]); - const subscribeToPriceUpdates = useCallback(() => { - try { - const unsubscribe = Engine.context.PerpsController.subscribeToPrices({ - symbols: [coin], - callback: (priceUpdates) => { - const update = priceUpdates.find((p) => p.coin === coin); - if (update) { - setPriceData(update); - } - }, - }); - return unsubscribe; - } catch (err) { - console.error('Error subscribing to price updates:', err); - return () => { - // Empty cleanup function on error - }; - } - }, [coin]); - // Load historical candles useEffect(() => { setIsLoadingHistory(true); @@ -78,15 +56,6 @@ export const usePerpsPositionData = ({ loadHistoricalData(); }, [fetchHistoricalCandles]); - // Subscribe to price updates for 24-hour data - useEffect(() => { - const unsubscribe = subscribeToPriceUpdates(); - - return () => { - unsubscribe(); - }; - }, [subscribeToPriceUpdates]); - // Refresh function to reload candle data const refreshCandleData = useCallback(async () => { setIsLoadingHistory(true); @@ -102,7 +71,6 @@ export const usePerpsPositionData = ({ return { candleData, - priceData, isLoadingHistory, refreshCandleData, }; diff --git a/app/components/UI/Perps/hooks/usePerpsPositions.test.ts b/app/components/UI/Perps/hooks/usePerpsPositions.test.ts deleted file mode 100644 index b15829edda8..00000000000 --- a/app/components/UI/Perps/hooks/usePerpsPositions.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { renderHook, act, waitFor } from '@testing-library/react-native'; -import { usePerpsPositions } from './usePerpsPositions'; -import { usePerpsTrading } from './usePerpsTrading'; -import { useFocusEffect } from '@react-navigation/native'; - -// Mock dependencies -jest.mock('./usePerpsTrading'); -jest.mock('@react-navigation/native', () => ({ - useFocusEffect: jest.fn(), -})); -jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ - DevLogger: { - log: jest.fn(), - }, -})); -jest.mock('../providers/PerpsConnectionProvider', () => ({ - usePerpsConnection: jest.fn(() => ({ - isInitialized: true, - isConnected: true, - })), -})); - -describe('usePerpsPositions', () => { - const mockGetPositions = jest.fn(); - const mockUseFocusEffect = useFocusEffect as jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - (usePerpsTrading as jest.Mock).mockReturnValue({ - getPositions: mockGetPositions, - }); - }); - - it('should load positions on mount by default', async () => { - mockGetPositions.mockResolvedValue([ - { coin: 'ETH', size: '1.5', unrealizedPnl: '100' }, - { coin: 'BTC', size: '0.1', unrealizedPnl: '50' }, - ]); - - const { result } = renderHook(() => usePerpsPositions()); - - expect(result.current.isLoading).toBe(true); - expect(result.current.positions).toEqual([]); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(mockGetPositions).toHaveBeenCalled(); - expect(result.current.positions).toHaveLength(2); - expect(result.current.error).toBe(null); - }); - - it('should not load on mount when loadOnMount is false', () => { - mockGetPositions.mockResolvedValue([]); - - renderHook(() => usePerpsPositions({ loadOnMount: false })); - - expect(mockGetPositions).not.toHaveBeenCalled(); - }); - - it('should handle errors correctly', async () => { - const testError = new Error('Failed to fetch positions'); - mockGetPositions.mockRejectedValue(testError); - const onError = jest.fn(); - - const { result } = renderHook(() => usePerpsPositions({ onError })); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.error).toBe('Failed to fetch positions'); - expect(result.current.positions).toEqual([]); - expect(onError).toHaveBeenCalledWith('Failed to fetch positions'); - }); - - it('should refresh positions with isRefresh flag', async () => { - mockGetPositions.mockResolvedValue([]); - - const { result } = renderHook(() => usePerpsPositions()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Reset mock to track refresh call - mockGetPositions.mockClear(); - mockGetPositions.mockResolvedValue([ - { coin: 'ETH', size: '2.0', unrealizedPnl: '200' }, - ]); - - await act(async () => { - await result.current.loadPositions({ isRefresh: true }); - }); - - expect(mockGetPositions).toHaveBeenCalled(); - expect(result.current.isRefreshing).toBe(false); - expect(result.current.positions).toHaveLength(1); - }); - - it('should call onSuccess callback when positions load successfully', async () => { - const onSuccess = jest.fn(); - const positions = [{ coin: 'ETH', size: '1.5', unrealizedPnl: '100' }]; - mockGetPositions.mockResolvedValue(positions); - - const { result } = renderHook(() => usePerpsPositions({ onSuccess })); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(onSuccess).toHaveBeenCalledWith(positions); - }); - - it('should setup focus effect when refreshOnFocus is true', () => { - renderHook(() => usePerpsPositions({ refreshOnFocus: true })); - - expect(mockUseFocusEffect).toHaveBeenCalled(); - }); - - it('should not setup focus effect when refreshOnFocus is false', () => { - renderHook(() => usePerpsPositions({ refreshOnFocus: false })); - - // The hook is still called but the callback won't trigger loadPositions - expect(mockUseFocusEffect).toHaveBeenCalled(); - }); -}); diff --git a/app/components/UI/Perps/hooks/usePerpsPrices.ts b/app/components/UI/Perps/hooks/usePerpsPrices.ts index 27d7d39c2cc..5c4552c4bb0 100644 --- a/app/components/UI/Perps/hooks/usePerpsPrices.ts +++ b/app/components/UI/Perps/hooks/usePerpsPrices.ts @@ -12,7 +12,7 @@ export interface UsePerpsPricesOptions { /** Whether to include order book data (bid/ask) */ includeOrderBook?: boolean; /** Debounce delay in milliseconds (default: 50ms) */ - debounceMs?: number; + throttleMs?: number; /** Whether to include market data (funding, OI, volume) */ includeMarketData?: boolean; } @@ -29,7 +29,7 @@ export function usePerpsPrices( ): Record { const { includeOrderBook = false, - debounceMs, + throttleMs, includeMarketData = false, } = options; @@ -67,7 +67,7 @@ export function usePerpsPrices( // Use provided debounce or fall back to default const debounceDelay = - debounceMs ?? PERFORMANCE_CONFIG.PRICE_UPDATE_DEBOUNCE_MS; + throttleMs ?? PERFORMANCE_CONFIG.PRICE_UPDATE_DEBOUNCE_MS; // Track if we've received the first update for each symbol // This only resets when symbols change, not debounce settings diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx index 712a4ef74d1..c8cd61b9d91 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx @@ -1,7 +1,11 @@ import React from 'react'; import { render, waitFor, act } from '@testing-library/react-native'; import { Text } from 'react-native'; -import { PerpsStreamProvider, usePerpsStream } from './PerpsStreamManager'; +import { + PerpsStreamProvider, + usePerpsStream, + PerpsStreamManager, +} from './PerpsStreamManager'; import Engine from '../../../../core/Engine'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import type { PriceUpdate } from '../controllers/types'; @@ -27,7 +31,7 @@ const TestPriceComponent = ({ callback: (prices: Record) => { onUpdate?.(prices); }, - debounceMs: 100, + throttleMs: 100, }); return () => { @@ -41,12 +45,16 @@ const TestPriceComponent = ({ describe('PerpsStreamManager', () => { let mockSubscribeToPrices: jest.Mock; let mockUnsubscribeFromPrices: jest.Mock; + let testStreamManager: PerpsStreamManager; beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); jest.useFakeTimers(); + // Create a fresh stream manager for each test + testStreamManager = new PerpsStreamManager(); + // Setup default mocks mockSubscribeToPrices = jest.fn(); mockUnsubscribeFromPrices = jest.fn(); @@ -60,12 +68,14 @@ describe('PerpsStreamManager', () => { }); afterEach(() => { + jest.clearAllTimers(); jest.useRealTimers(); + jest.clearAllMocks(); }); it('should render children correctly', () => { const { getByText } = render( - + Child Component , ); @@ -91,13 +101,395 @@ describe('PerpsStreamManager', () => { console.error = originalError; }); + it('should provide immediate cached data on subscription', async () => { + // Setup mock subscription that will trigger updates + mockSubscribeToPrices.mockImplementation( + (params: { callback: (updates: PriceUpdate[]) => void }) => { + // Simulate immediate cached data with all required fields + const cachedData: PriceUpdate[] = [ + { + coin: 'BTC-PERP', + price: '50000', + percentChange24h: '5', + timestamp: Date.now(), + bestBid: '49900', + bestAsk: '50100', + spread: '200', + markPrice: '50050', + }, + ]; + params.callback(cachedData); + return jest.fn(); + }, + ); + + const onUpdate = jest.fn(); + + render( + + + , + ); + + // Should receive cached data immediately + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith({ + 'BTC-PERP': { + coin: 'BTC-PERP', + price: '50000', + timestamp: expect.any(Number), + percentChange24h: '5', + bestBid: '49900', + bestAsk: '50100', + spread: '200', + markPrice: '50050', + funding: undefined, + openInterest: undefined, + volume24h: undefined, + }, + }); + }); + }); + + it('should throttle updates after first immediate update', async () => { + let controllerCallback: ((updates: PriceUpdate[]) => void) | null = null; + mockSubscribeToPrices.mockImplementation( + (params: { callback: (updates: PriceUpdate[]) => void }) => { + controllerCallback = params.callback; + return jest.fn(); + }, + ); + + const onUpdate = jest.fn(); + + render( + + + , + ); + + // Wait for subscription setup + await waitFor(() => { + expect(mockSubscribeToPrices).toHaveBeenCalled(); + }); + + // First update should be immediate + act(() => { + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50000', + percentChange24h: '5', + timestamp: Date.now(), + }, + ]); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + // Subsequent updates should be throttled + act(() => { + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50100', + percentChange24h: '5.1', + timestamp: Date.now(), + }, + ]); + }); + + // Should not be called immediately + expect(onUpdate).toHaveBeenCalledTimes(1); + + // Fast-forward time to trigger throttled update + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2); + expect(onUpdate).toHaveBeenLastCalledWith({ + 'BTC-PERP': { + coin: 'BTC-PERP', + price: '50100', + timestamp: expect.any(Number), + percentChange24h: '5.1', + bestBid: undefined, + bestAsk: undefined, + spread: undefined, + markPrice: undefined, + funding: undefined, + openInterest: undefined, + volume24h: undefined, + }, + }); + }); + }); + + it('should handle multiple rapid updates with throttling', async () => { + let controllerCallback: ((updates: PriceUpdate[]) => void) | null = null; + mockSubscribeToPrices.mockImplementation( + (params: { callback: (updates: PriceUpdate[]) => void }) => { + controllerCallback = params.callback; + return jest.fn(); + }, + ); + + const onUpdate = jest.fn(); + + render( + + + , + ); + + // Wait for subscription setup + await waitFor(() => { + expect(mockSubscribeToPrices).toHaveBeenCalled(); + }); + + // First update (immediate) + act(() => { + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50000', + percentChange24h: '5', + timestamp: Date.now(), + }, + ]); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + // Multiple rapid updates during throttle period + act(() => { + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50100', + percentChange24h: '5.1', + timestamp: Date.now(), + }, + ]); + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50200', + percentChange24h: '5.2', + timestamp: Date.now(), + }, + ]); + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50300', + percentChange24h: '5.3', + timestamp: Date.now(), + }, + ]); + }); + + // Still only 1 call (first immediate) + expect(onUpdate).toHaveBeenCalledTimes(1); + + // Advance timer to trigger throttled update + act(() => { + jest.advanceTimersByTime(100); + }); + + // Should receive the latest update + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2); + expect(onUpdate).toHaveBeenLastCalledWith({ + 'BTC-PERP': { + coin: 'BTC-PERP', + price: '50300', + timestamp: expect.any(Number), + percentChange24h: '5.3', + bestBid: undefined, + bestAsk: undefined, + spread: undefined, + markPrice: undefined, + funding: undefined, + openInterest: undefined, + volume24h: undefined, + }, + }); + }); + }); + + it('should handle subscription without throttling', async () => { + let controllerCallback: ((updates: PriceUpdate[]) => void) | null = null; + mockSubscribeToPrices.mockImplementation( + (params: { callback: (updates: PriceUpdate[]) => void }) => { + controllerCallback = params.callback; + return jest.fn(); + }, + ); + + const TestNoThrottleComponent = ({ + onUpdate, + }: { + onUpdate?: (prices: Record) => void; + }) => { + const stream = usePerpsStream(); + + React.useEffect(() => { + const unsubscribe = stream.prices.subscribeToSymbols({ + symbols: ['ETH-PERP'], + callback: (prices: Record) => { + onUpdate?.(prices); + }, + throttleMs: 0, // No throttling + }); + + return () => { + unsubscribe(); + }; + }, [stream, onUpdate]); + + return No Throttle; + }; + + const onUpdate = jest.fn(); + + render( + + + , + ); + + // Wait for subscription setup + await waitFor(() => { + expect(mockSubscribeToPrices).toHaveBeenCalled(); + }); + + // Reset the mock call count after initial setup + onUpdate.mockClear(); + + // All updates should be immediate when throttleMs is 0 + act(() => { + controllerCallback?.([ + { + coin: 'ETH-PERP', + price: '3000', + timestamp: Date.now(), + percentChange24h: '2', + }, + ]); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + act(() => { + controllerCallback?.([ + { + coin: 'ETH-PERP', + price: '3010', + timestamp: Date.now(), + percentChange24h: '2.1', + }, + ]); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2); + }); + + act(() => { + controllerCallback?.([ + { + coin: 'ETH-PERP', + price: '3020', + timestamp: Date.now(), + percentChange24h: '2.2', + }, + ]); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(3); + }); + }); + + it('should clean up timers on unsubscribe', async () => { + let controllerCallback: ((updates: PriceUpdate[]) => void) | null = null; + const unsubscribeMock = jest.fn(); + mockSubscribeToPrices.mockImplementation( + (params: { callback: (updates: PriceUpdate[]) => void }) => { + controllerCallback = params.callback; + return unsubscribeMock; + }, + ); + + const onUpdate = jest.fn(); + + const { unmount } = render( + + + , + ); + + // Wait for subscription setup + await waitFor(() => { + expect(mockSubscribeToPrices).toHaveBeenCalled(); + }); + + // First update (immediate) + act(() => { + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50000', + timestamp: Date.now(), + percentChange24h: '5', + }, + ]); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + // Queue a throttled update + act(() => { + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50100', + timestamp: Date.now(), + percentChange24h: '5.1', + }, + ]); + }); + + // Unmount before timer fires + unmount(); + + // Advance timer + act(() => { + jest.advanceTimersByTime(100); + }); + + // Should not receive update after unmount + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + it('should subscribe to prices when component mounts', async () => { mockSubscribeToPrices.mockImplementation(() => jest.fn()); const onUpdate = jest.fn(); render( - + , ); @@ -116,7 +508,7 @@ describe('PerpsStreamManager', () => { mockSubscribeToPrices.mockReturnValue(mockUnsubscribe); const { unmount } = render( - + , ); @@ -143,7 +535,7 @@ describe('PerpsStreamManager', () => { }); render( - + , ); @@ -175,7 +567,7 @@ describe('PerpsStreamManager', () => { }); }); - it('should debounce subsequent updates', async () => { + it('should throttle subsequent updates', async () => { const onUpdate = jest.fn(); let priceCallback: (data: PriceUpdate[]) => void = jest.fn(); @@ -185,7 +577,7 @@ describe('PerpsStreamManager', () => { }); render( - + , ); @@ -233,12 +625,12 @@ describe('PerpsStreamManager', () => { // Should not be called immediately expect(onUpdate).toHaveBeenCalledTimes(1); - // Advance timers to trigger debounce + // Advance timers to trigger throttle act(() => { jest.advanceTimersByTime(100); }); - // Should receive the last update after debounce + // Should receive the last update after throttle await waitFor(() => { expect(onUpdate).toHaveBeenCalledTimes(2); const lastCall = onUpdate.mock.calls[1][0]; @@ -249,7 +641,7 @@ describe('PerpsStreamManager', () => { }); }); - it('should handle multiple subscribers with different debounce times', async () => { + it('should handle multiple subscribers with different throttle times', async () => { const onUpdate1 = jest.fn(); const onUpdate2 = jest.fn(); let priceCallback: (data: PriceUpdate[]) => void = jest.fn(); @@ -268,7 +660,7 @@ describe('PerpsStreamManager', () => { callback: (prices: Record) => { onUpdate1(prices); }, - debounceMs: 100, + throttleMs: 100, }); const sub2 = stream.prices.subscribeToSymbols({ @@ -276,7 +668,7 @@ describe('PerpsStreamManager', () => { callback: (prices: Record) => { onUpdate2(prices); }, - debounceMs: 200, + throttleMs: 200, }); return () => { @@ -289,7 +681,7 @@ describe('PerpsStreamManager', () => { }; render( - + , ); @@ -365,7 +757,7 @@ describe('PerpsStreamManager', () => { callback: (prices: Record) => { onUpdate(prices); }, - debounceMs: 100, + throttleMs: 100, }); return () => { @@ -379,7 +771,7 @@ describe('PerpsStreamManager', () => { const onUpdate = jest.fn(); render( - + , ); @@ -397,7 +789,7 @@ describe('PerpsStreamManager', () => { mockSubscribeToPrices.mockReturnValue(mockUnsubscribe); const { unmount } = render( - + , ); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index 90633a45f34..ba73eca011e 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -1,6 +1,5 @@ import React, { createContext, useContext } from 'react'; import Engine from '../../../../core/Engine'; -import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import type { PriceUpdate, Position, @@ -12,7 +11,7 @@ import type { interface StreamSubscription { id: string; callback: (data: T) => void; - debounceMs: number; + throttleMs?: number; timer?: NodeJS.Timeout; pendingUpdate?: T; hasReceivedFirstUpdate?: boolean; // Track if subscriber has received first update @@ -28,46 +27,40 @@ class StreamChannel { this.subscribers.forEach((subscriber) => { // Check if this is the first update for this subscriber if (!subscriber.hasReceivedFirstUpdate) { - DevLogger.log( - `StreamChannel: First update for subscriber ${subscriber.id}, executing immediately`, - ); subscriber.callback(updates); subscriber.hasReceivedFirstUpdate = true; - return; // Don't set up debounce for the first update + return; // Don't set up throttle for the first update } - // For subsequent updates, use debounce logic + // If no throttling (throttleMs is 0 or undefined), notify immediately + if (!subscriber.throttleMs) { + subscriber.callback(updates); + return; + } + + // For subsequent updates with throttling, use throttle logic // Store pending update subscriber.pendingUpdate = updates; // Only set timer if one isn't already running if (!subscriber.timer) { - DevLogger.log( - `StreamChannel: Setting ${subscriber.debounceMs}ms timer for subscriber ${subscriber.id}`, - ); subscriber.timer = setTimeout(() => { if (subscriber.pendingUpdate) { - DevLogger.log( - `StreamChannel: Executing callback for subscriber ${subscriber.id} after ${subscriber.debounceMs}ms`, - ); subscriber.callback(subscriber.pendingUpdate); subscriber.pendingUpdate = undefined; subscriber.timer = undefined; } - }, subscriber.debounceMs); + }, subscriber.throttleMs); } }); } subscribe(params: { callback: (data: T) => void; - debounceMs: number; + throttleMs?: number; }): () => void { const id = Math.random().toString(36); - DevLogger.log( - `StreamChannel: New subscriber ${id} with ${params.debounceMs}ms debounce`, - ); const subscription: StreamSubscription = { id, ...params, @@ -78,7 +71,6 @@ class StreamChannel { // Give immediate cached data if available const cached = this.getCachedData(); if (cached) { - DevLogger.log(`StreamChannel: Providing cached data to subscriber ${id}`); params.callback(cached); // Mark as having received first update since we provided cached data subscription.hasReceivedFirstUpdate = true; @@ -88,7 +80,6 @@ class StreamChannel { this.connect(); return () => { - DevLogger.log(`StreamChannel: Removing subscriber ${id}`); const sub = this.subscribers.get(id); if (sub?.timer) { clearTimeout(sub.timer); @@ -97,15 +88,13 @@ class StreamChannel { // Disconnect if no subscribers if (this.subscribers.size === 0) { - DevLogger.log('StreamChannel: No subscribers left, disconnecting'); this.disconnect(); } }; } protected connect() { - // Template method for establishing WebSocket connections - // Each stream type overrides this with specific subscription logic + // Override in subclasses } protected disconnect() { @@ -116,8 +105,7 @@ class StreamChannel { } protected getCachedData(): T | null { - // Template method for retrieving cached data for new subscribers - // Each stream type overrides this to return their specific cached format + // Override in subclasses return null; } } @@ -130,7 +118,6 @@ class PriceStreamChannel extends StreamChannel> { protected connect() { if (this.wsSubscription) { - DevLogger.log('PriceStream: Already connected'); return; } @@ -138,14 +125,9 @@ class PriceStreamChannel extends StreamChannel> { const allSymbols = Array.from(this.symbols); if (allSymbols.length === 0) { - DevLogger.log('PriceStream: No symbols to subscribe to yet'); return; } - DevLogger.log('PriceStream: Establishing WebSocket subscription', { - symbols: allSymbols, - }); - this.wsSubscription = Engine.context.PerpsController.subscribeToPrices({ symbols: allSymbols, // Subscribe to specific symbols callback: (updates: PriceUpdate[]) => { @@ -173,7 +155,6 @@ class PriceStreamChannel extends StreamChannel> { this.notifySubscribers(priceMap); }, }); - DevLogger.log('PriceStream: WebSocket subscription established'); } protected getCachedData(): Record | null { @@ -188,7 +169,7 @@ class PriceStreamChannel extends StreamChannel> { subscribeToSymbols(params: { symbols: string[]; callback: (prices: Record) => void; - debounceMs: number; + throttleMs?: number; }): () => void { // Track new symbols const newSymbols: string[] = []; @@ -199,16 +180,8 @@ class PriceStreamChannel extends StreamChannel> { this.symbols.add(s); }); - DevLogger.log( - `PriceStream: Component subscribing to symbols with ${params.debounceMs}ms debounce`, - { symbols: params.symbols, newSymbols }, - ); - // If we have new symbols and WebSocket is already connected, we need to reconnect if (newSymbols.length > 0 && this.wsSubscription) { - DevLogger.log( - 'PriceStream: New symbols detected, reconnecting WebSocket', - ); this.disconnect(); this.connect(); } @@ -222,51 +195,73 @@ class PriceStreamChannel extends StreamChannel> { filtered[symbol] = allPrices[symbol]; } }); - DevLogger.log( - `PriceStream: Sending filtered prices to component (${params.debounceMs}ms debounce)`, - { - symbols: Object.keys(filtered), - count: Object.keys(filtered).length, - }, - ); params.callback(filtered); }, - debounceMs: params.debounceMs, + throttleMs: params.throttleMs, }); } } // Specific channel for orders class OrderStreamChannel extends StreamChannel { + private prewarmUnsubscribe?: () => void; + protected connect() { if (this.wsSubscription) return; - // For now, we'll use polling until WebSocket is available - // This will be replaced with actual WebSocket subscription - const controller = Engine.context.PerpsController; - const pollInterval = setInterval(async () => { - try { - const orders = await controller.getOrders(); + // This calls HyperLiquidSubscriptionService.subscribeToOrders which uses shared webData2 + this.wsSubscription = Engine.context.PerpsController.subscribeToOrders({ + callback: (orders: Order[]) => { this.cache.set('orders', orders); this.notifySubscribers(orders); - } catch (error) { - // Handle error silently - } - }, 5000); - - this.wsSubscription = () => clearInterval(pollInterval); + }, + }); } protected getCachedData() { return this.cache.get('orders') || []; } + + /** + * Pre-warm the channel by creating a persistent subscription + * This keeps the WebSocket connection alive and caches data continuously + * @returns Cleanup function to call when leaving Perps environment + */ + public prewarm(): () => void { + if (this.prewarmUnsubscribe) { + return this.prewarmUnsubscribe; + } + + // Create a real subscription with no-op callback to keep connection alive + this.prewarmUnsubscribe = this.subscribe({ + callback: () => { + // No-op callback - just keeps the connection alive for caching + }, + throttleMs: 0, // No throttle for pre-warm + }); + + return this.prewarmUnsubscribe; + } + + /** + * Cleanup pre-warm subscription + */ + public cleanupPrewarm(): void { + if (this.prewarmUnsubscribe) { + this.prewarmUnsubscribe(); + this.prewarmUnsubscribe = undefined; + } + } } // Specific channel for positions class PositionStreamChannel extends StreamChannel { + private prewarmUnsubscribe?: () => void; + protected connect() { if (this.wsSubscription) return; + // This calls HyperLiquidSubscriptionService.subscribeToPositions which uses shared webData2 this.wsSubscription = Engine.context.PerpsController.subscribeToPositions({ callback: (positions: Position[]) => { this.cache.set('positions', positions); @@ -278,6 +273,37 @@ class PositionStreamChannel extends StreamChannel { protected getCachedData() { return this.cache.get('positions') || []; } + + /** + * Pre-warm the channel by creating a persistent subscription + * This keeps the WebSocket connection alive and caches data continuously + * @returns Cleanup function to call when leaving Perps environment + */ + public prewarm(): () => void { + if (this.prewarmUnsubscribe) { + return this.prewarmUnsubscribe; + } + + // Create a real subscription with no-op callback to keep connection alive + this.prewarmUnsubscribe = this.subscribe({ + callback: () => { + // No-op callback - just keeps the connection alive for caching + }, + throttleMs: 0, // No throttle for pre-warm + }); + + return this.prewarmUnsubscribe; + } + + /** + * Cleanup pre-warm subscription + */ + public cleanupPrewarm(): void { + if (this.prewarmUnsubscribe) { + this.prewarmUnsubscribe(); + this.prewarmUnsubscribe = undefined; + } + } } // Specific channel for fills @@ -302,7 +328,7 @@ class FillStreamChannel extends StreamChannel { } // Main manager class -class PerpsStreamManager { +export class PerpsStreamManager { public readonly prices = new PriceStreamChannel(); public readonly orders = new OrderStreamChannel(); public readonly positions = new PositionStreamChannel(); @@ -318,13 +344,17 @@ class PerpsStreamManager { // Singleton instance const streamManager = new PerpsStreamManager(); +// Export singleton for pre-warming in PerpsConnectionManager +export const getStreamManagerInstance = () => streamManager; + // Context const PerpsStreamContext = createContext(null); export const PerpsStreamProvider: React.FC<{ children: React.ReactNode; -}> = ({ children }) => ( - + testStreamManager?: PerpsStreamManager; // Only for testing +}> = ({ children, testStreamManager }) => ( + {children} ); diff --git a/app/components/UI/Perps/providers/__mocks__/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/__mocks__/PerpsStreamManager.tsx new file mode 100644 index 00000000000..ec41d6e9480 --- /dev/null +++ b/app/components/UI/Perps/providers/__mocks__/PerpsStreamManager.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +// Mock stream manager +const mockStreamManager = { + prices: { + subscribeToSymbols: jest.fn(() => jest.fn()), + subscribe: jest.fn(() => jest.fn()), + }, + orders: { + subscribe: jest.fn(() => jest.fn()), + }, + positions: { + subscribe: jest.fn(() => jest.fn()), + }, + fills: { + subscribe: jest.fn(() => jest.fn()), + }, +}; + +// Mock provider component +export const PerpsStreamProvider = ({ + children, +}: { + children: React.ReactNode; +}) => <>{children}; + +// Mock hook +export const usePerpsStream = jest.fn(() => mockStreamManager); + +// Export the mock stream manager for test access +export const getStreamManagerInstance = () => mockStreamManager; diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts index 336e486cdb4..768a9b6b80d 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts @@ -30,6 +30,22 @@ jest.mock('../utils/hyperLiquidAdapter', () => ({ averagePrice: '50000', markPrice: '52000', })), + adaptOrderFromSDK: jest.fn((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: false, + reduceOnly: false, + })), })); // Mock DevLogger @@ -103,7 +119,8 @@ describe('HyperLiquidSubscriptionService', () => { return Promise.resolve(mockSubscription); }), webData2: jest.fn((_params: any, callback: any) => { - // Simulate position data + // Simulate position and order data + // First callback immediately setTimeout(() => { callback({ clearinghouseState: { @@ -114,8 +131,51 @@ describe('HyperLiquidSubscriptionService', () => { }, ], }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], }); }, 0); + + // Second callback with changed data to ensure updates are triggered + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.2' }, // Changed position size + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12346, // Changed order ID + coin: 'BTC', + side: 'S', + sz: '0.3', + origSz: '0.5', + limitPx: '51000', + orderType: 'Limit', + timestamp: 1234567890001, + isTrigger: false, + reduceOnly: false, + }, + ], + }); + }, 10); + return Promise.resolve(mockSubscription); }), userFills: jest.fn((_params: any, callback: any) => { @@ -438,6 +498,145 @@ describe('HyperLiquidSubscriptionService', () => { }); }); + describe('Shared WebData2 Subscription', () => { + it('should share webData2 subscription between positions and orders', async () => { + const positionCallback = jest.fn(); + const orderCallback = jest.fn(); + + // Mock getUserAddressWithDefault to return immediately + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x123' as Hex, + ); + + // Subscribe to positions first + const unsubscribePositions = service.subscribeToPositions({ + callback: positionCallback, + }); + + // Wait for subscription to be established and initial callback + // This will trigger the first webData2 callback which caches both positions and orders + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Verify position callback was called + expect(positionCallback).toHaveBeenCalled(); + + // Subscribe to orders - should reuse same webData2 subscription + // and immediately get cached data + const unsubscribeOrders = service.subscribeToOrders({ + callback: orderCallback, + }); + + // Orders should get cached data immediately (synchronously) + // or after the second webData2 update with changed data + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Should only call webData2 once for shared subscription + expect(mockSubscriptionClient.webData2).toHaveBeenCalledTimes(1); + + // Both callbacks should be called with their respective data + expect(positionCallback).toHaveBeenCalled(); + expect(orderCallback).toHaveBeenCalled(); + + // Cleanup + unsubscribePositions(); + unsubscribeOrders(); + }); + + it('should maintain subscription when one subscriber unsubscribes', async () => { + const positionCallback1 = jest.fn(); + const positionCallback2 = jest.fn(); + + // Subscribe two position callbacks + const unsubscribe1 = service.subscribeToPositions({ + callback: positionCallback1, + }); + + const unsubscribe2 = service.subscribeToPositions({ + callback: positionCallback2, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Unsubscribe first callback + unsubscribe1(); + + // Second callback should still receive updates + mockSubscriptionClient.webData2.mock.calls[0][1]({ + clearinghouseState: { + assetPositions: [ + { + position: { coin: 'BTC', szi: '1.0' }, + }, + ], + }, + openOrders: [], + }); + + expect(positionCallback2).toHaveBeenCalled(); + + unsubscribe2(); + }); + + it('should cache positions and orders data', async () => { + const positionCallback = jest.fn(); + + // Setup webData2 mock to call callback with data + mockSubscriptionClient.webData2.mockImplementation( + (_addr: any, callback: any) => { + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 123, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '0.5', + limitPx: '50000', + orderType: 'Limit', + timestamp: Date.now(), + isTrigger: false, + reduceOnly: false, + }, + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: positionCallback, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Should receive cached data on new subscription + const newCallback = jest.fn(); + const unsubscribe2 = service.subscribeToPositions({ + callback: newCallback, + }); + + // New subscriber should get cached data immediately + expect(newCallback).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ coin: 'BTC' })]), + ); + + unsubscribe(); + unsubscribe2(); + }); + }); + describe('Subscription Lifecycle', () => { it('should unsubscribe from position updates successfully', async () => { const mockCallback = jest.fn(); @@ -741,8 +940,9 @@ describe('HyperLiquidSubscriptionService', () => { setTimeout(() => { callback({ clearinghouseState: { - // No assetPositions + assetPositions: [], // Empty array instead of undefined }, + openOrders: [], // Also need openOrders array }); }, 0); return Promise.resolve({ diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts index b2393615b4f..cec3f678596 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts @@ -20,13 +20,19 @@ import type { PriceUpdate, Position, OrderFill, + Order, SubscribePricesParams, SubscribePositionsParams, SubscribeOrderFillsParams, + SubscribeOrdersParams, } from '../controllers/types'; -import { adaptPositionFromSDK } from '../utils/hyperLiquidAdapter'; +import { + adaptPositionFromSDK, + adaptOrderFromSDK, +} from '../utils/hyperLiquidAdapter'; import type { HyperLiquidClientService } from './HyperLiquidClientService'; import type { HyperLiquidWalletService } from './HyperLiquidWalletService'; +import type { CaipAccountId } from '@metamask/utils'; import { strings } from '../../../../../locales/i18n'; /** @@ -45,6 +51,7 @@ export class HyperLiquidSubscriptionService { >(); private positionSubscribers = new Set<(positions: Position[]) => void>(); private orderFillSubscribers = new Set<(fills: OrderFill[]) => void>(); + private orderSubscribers = new Set<(orders: Order[]) => void>(); // Track which subscribers want market data private marketDataSubscribers = new Map< @@ -58,6 +65,14 @@ export class HyperLiquidSubscriptionService { private globalL2BookSubscriptions = new Map(); private symbolSubscriberCounts = new Map(); + // Shared webData2 subscription for positions and orders + private sharedWebData2Subscription?: Subscription; + private webData2SubscriptionPromise?: Promise; + private positionSubscriberCount = 0; + private orderSubscriberCount = 0; + private cachedPositions: Position[] = []; + private cachedOrders: Order[] = []; + // Global price data cache private cachedPriceData = new Map(); @@ -187,68 +202,214 @@ export class HyperLiquidSubscriptionService { } /** - * Subscribe to live position updates + * Ensure shared webData2 subscription is active (singleton pattern) + * This subscription provides both positions and orders data */ - public subscribeToPositions(params: SubscribePositionsParams): () => void { - const { callback, accountId } = params; - const unsubscribe = this.createSubscription( - this.positionSubscribers, - callback, - ); + private async ensureSharedWebData2Subscription( + accountId?: CaipAccountId, + ): Promise { + // Return existing subscription if active + if (this.sharedWebData2Subscription) { + return; + } - let subscription: Subscription | undefined; + // Return existing promise if subscription is being established + if (this.webData2SubscriptionPromise) { + return this.webData2SubscriptionPromise; + } + // Create new subscription promise to prevent race conditions + this.webData2SubscriptionPromise = + this.createWebData2Subscription(accountId); + + try { + await this.webData2SubscriptionPromise; + } catch (error) { + // Clear promise on error so it can be retried + this.webData2SubscriptionPromise = undefined; + throw error; + } + } + + /** + * Create the actual webData2 subscription + */ + private async createWebData2Subscription( + accountId?: CaipAccountId, + ): Promise { this.clientService.ensureSubscriptionClient( this.walletService.createWalletAdapter(), ); const subscriptionClient = this.clientService.getSubscriptionClient(); - if (subscriptionClient) { - this.walletService - .getUserAddressWithDefault(accountId) - .then((userAddress) => { - if (!subscriptionClient) { - throw new Error( - strings('perps.errors.subscriptionClientNotInitialized'), - ); - } + if (!subscriptionClient) { + throw new Error(strings('perps.errors.subscriptionClientNotInitialized')); + } - return subscriptionClient.webData2( - { user: userAddress }, - (data: WsWebData2) => { - if (data.clearinghouseState.assetPositions) { - const positions: Position[] = - data.clearinghouseState.assetPositions - .filter((assetPos) => assetPos.position.szi !== '0') - .map((assetPos) => adaptPositionFromSDK(assetPos)); - - callback(positions); + const userAddress = await this.walletService.getUserAddressWithDefault( + accountId, + ); + + return new Promise((resolve, reject) => { + subscriptionClient + .webData2({ user: userAddress }, (data: WsWebData2) => { + // Extract and process positions with TP/SL data + const positions = data.clearinghouseState.assetPositions + .filter((assetPos) => assetPos.position.szi !== '0') + .map((assetPos) => adaptPositionFromSDK(assetPos)); + + // Extract TP/SL from openOrders for positions + const tpslMap = new Map< + string, + { takeProfitPrice?: string; stopLossPrice?: string } + >(); + + // Also extract regular orders for order subscribers + const orders: Order[] = []; + + (data.openOrders || []).forEach((order) => { + // Process trigger orders for TP/SL + if (order.triggerPx) { + const coin = order.coin; + const position = positions.find((p) => p.coin === coin); + + if (position) { + const existing = tpslMap.get(coin) || {}; + const isLong = parseFloat(position.size) > 0; + + // Determine if it's TP or SL based on order type + if (order.orderType?.includes('Take Profit')) { + existing.takeProfitPrice = order.triggerPx; + } else if (order.orderType?.includes('Stop')) { + existing.stopLossPrice = order.triggerPx; + } else { + // Fallback: determine based on trigger price vs entry price + const triggerPrice = parseFloat(order.triggerPx); + const entryPrice = parseFloat(position.entryPrice || '0'); + + if (isLong) { + if (triggerPrice > entryPrice) { + existing.takeProfitPrice = order.triggerPx; + } else { + existing.stopLossPrice = order.triggerPx; + } + } else if (triggerPrice < entryPrice) { + existing.takeProfitPrice = order.triggerPx; + } else { + existing.stopLossPrice = order.triggerPx; + } + } + + tpslMap.set(coin, existing); } - }, - ); + } + + // Convert ALL open orders to Order format using adapter + // We NO LONGER skip TP/SL orders - they should appear in the orders list too + // TP/SL orders are both: + // 1. Used to populate position TP/SL fields (done above) + // 2. Shown as separate orders in the orders list (done here) + const convertedOrder = adaptOrderFromSDK(order); + orders.push(convertedOrder); + }); + + // Merge positions with TP/SL data, ensuring fields are always present + const positionsWithTPSL = positions.map((position) => { + const tpsl = tpslMap.get(position.coin) || {}; + return { + ...position, + takeProfitPrice: tpsl.takeProfitPrice || undefined, + stopLossPrice: tpsl.stopLossPrice || undefined, + }; + }); + + // Check if positions actually changed + const positionsChanged = + JSON.stringify(positionsWithTPSL) !== + JSON.stringify(this.cachedPositions); + const ordersChanged = + JSON.stringify(orders) !== JSON.stringify(this.cachedOrders); + + // Only update and notify if data actually changed + if (positionsChanged) { + this.cachedPositions = positionsWithTPSL; + this.positionSubscribers.forEach((callback) => { + callback(positionsWithTPSL); + }); + } + + if (ordersChanged) { + this.cachedOrders = orders; + this.orderSubscribers.forEach((callback) => { + callback(orders); + }); + } }) .then((sub) => { - subscription = sub; + this.sharedWebData2Subscription = sub; + DevLogger.log( + 'Shared webData2 subscription established (single connection for positions + orders)', + ); + resolve(); }) .catch((error) => { DevLogger.log( - strings('perps.errors.failedToSubscribePosition'), + 'Failed to establish shared webData2 subscription', error, ); + reject(error instanceof Error ? error : new Error(String(error))); }); + }); + } + + /** + * Clean up shared webData2 subscription when no longer needed + */ + private cleanupSharedWebData2Subscription(): void { + const totalSubscribers = + this.positionSubscriberCount + this.orderSubscriberCount; + + if (totalSubscribers <= 0 && this.sharedWebData2Subscription) { + this.sharedWebData2Subscription.unsubscribe().catch((error: Error) => { + DevLogger.log('Failed to unsubscribe shared webData2', error); + }); + this.sharedWebData2Subscription = undefined; + this.webData2SubscriptionPromise = undefined; + this.positionSubscriberCount = 0; + this.orderSubscriberCount = 0; + this.cachedPositions = []; + this.cachedOrders = []; + DevLogger.log('Shared webData2 subscription cleaned up'); } + } + + /** + * Subscribe to live position updates with TP/SL data + */ + public subscribeToPositions(params: SubscribePositionsParams): () => void { + const { callback, accountId } = params; + const unsubscribe = this.createSubscription( + this.positionSubscribers, + callback, + ); + + // Increment position subscriber count + this.positionSubscriberCount++; + + // Immediately provide cached data if available + if (this.cachedPositions.length > 0) { + callback(this.cachedPositions); + } + + // Ensure shared subscription is active + this.ensureSharedWebData2Subscription(accountId).catch((error) => { + DevLogger.log(strings('perps.errors.failedToSubscribePosition'), error); + }); return () => { unsubscribe(); - - if (subscription) { - subscription.unsubscribe().catch((error: Error) => { - DevLogger.log( - strings('perps.errors.failedToUnsubscribePosition'), - error, - ); - }); - } + this.positionSubscriberCount--; + this.cleanupSharedWebData2Subscription(); }; } @@ -325,6 +486,37 @@ export class HyperLiquidSubscriptionService { }; } + /** + * Subscribe to live order updates + * Uses the shared webData2 subscription to avoid duplicate connections + */ + public subscribeToOrders(params: SubscribeOrdersParams): () => void { + const { callback, accountId } = params; + const unsubscribe = this.createSubscription( + this.orderSubscribers, + callback, + ); + + // Increment order subscriber count + this.orderSubscriberCount++; + + // Immediately provide cached data if available + if (this.cachedOrders.length > 0) { + callback(this.cachedOrders); + } + + // Ensure shared subscription is active + this.ensureSharedWebData2Subscription(accountId).catch((error) => { + DevLogger.log(strings('perps.errors.failedToSubscribeOrders'), error); + }); + + return () => { + unsubscribe(); + this.orderSubscriberCount--; + this.cleanupSharedWebData2Subscription(); + }; + } + /** * Create subscription with common error handling */ @@ -720,6 +912,8 @@ export class HyperLiquidSubscriptionService { this.globalAllMidsSubscription = undefined; this.globalActiveAssetSubscriptions.clear(); this.globalL2BookSubscriptions.clear(); + this.sharedWebData2Subscription = undefined; + this.webData2SubscriptionPromise = undefined; DevLogger.log('HyperLiquid: Subscription service cleared', { timestamp: new Date().toISOString(), diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index f8fe9d331f8..fd864f2c71e 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -1,5 +1,6 @@ import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import Engine from '../../../../core/Engine'; +import { getStreamManagerInstance } from '../providers/PerpsStreamManager'; /** * Singleton manager for Perps connection state @@ -13,6 +14,8 @@ class PerpsConnectionManagerClass { private isInitialized = false; private connectionRefCount = 0; private initPromise: Promise | null = null; + private hasPreloaded = false; + private prewarmCleanups: (() => void)[] = []; private constructor() { // Private constructor to enforce singleton pattern @@ -69,6 +72,9 @@ class PerpsConnectionManagerClass { this.isConnected = true; this.isConnecting = false; DevLogger.log('PerpsConnectionManager: Successfully connected'); + + // Pre-load positions and orders subscriptions to populate cache + await this.preloadSubscriptions(); } catch (error) { this.isConnecting = false; this.isConnected = false; @@ -98,10 +104,15 @@ class PerpsConnectionManagerClass { DevLogger.log( 'PerpsConnectionManager: Disconnecting (no more references)', ); + + // Clean up preloaded subscriptions + this.cleanupPreloadedSubscriptions(); + // Reset state before disconnecting to prevent race conditions this.isConnected = false; this.isInitialized = false; this.isConnecting = false; + this.hasPreloaded = false; // Reset pre-load flag on disconnect await Engine.context.PerpsController.disconnect(); } catch (error) { @@ -111,6 +122,83 @@ class PerpsConnectionManagerClass { } } + /** + * Pre-load critical WebSocket subscriptions to populate cache + * This ensures positions and orders are available immediately when components mount + * Uses the StreamManager singleton to ensure single WebSocket connections + */ + private async preloadSubscriptions(): Promise { + // Only pre-load once per session + if (this.hasPreloaded) { + DevLogger.log('PerpsConnectionManager: Already pre-loaded, skipping'); + return; + } + + try { + DevLogger.log( + 'PerpsConnectionManager: Pre-loading WebSocket subscriptions via StreamManager', + ); + this.hasPreloaded = true; + + // Get the singleton StreamManager instance + const streamManager = getStreamManagerInstance(); + + // Pre-warm the positions and orders channels + // This creates persistent subscriptions that keep connections alive + // Store cleanup functions to call when leaving Perps + const positionCleanup = streamManager.positions.prewarm(); + const orderCleanup = streamManager.orders.prewarm(); + + this.prewarmCleanups.push(positionCleanup, orderCleanup); + + // Give subscriptions a moment to receive initial data + await new Promise((resolve) => setTimeout(resolve, 100)); + + DevLogger.log( + 'PerpsConnectionManager: Pre-loading complete with persistent subscriptions', + ); + } catch (error) { + DevLogger.log( + 'PerpsConnectionManager: Failed to pre-load subscriptions', + error, + ); + // Non-critical error - components will still work with on-demand subscriptions + } + } + + /** + * Clean up pre-loaded subscriptions + * Called when leaving the Perps environment + */ + private cleanupPreloadedSubscriptions(): void { + if (this.prewarmCleanups.length === 0) { + DevLogger.log( + 'PerpsConnectionManager: No pre-warm subscriptions to cleanup', + ); + return; + } + + DevLogger.log( + `PerpsConnectionManager: Cleaning up ${this.prewarmCleanups.length} pre-warm subscriptions`, + ); + + // Call all cleanup functions + this.prewarmCleanups.forEach((cleanup) => { + try { + cleanup(); + } catch (error) { + DevLogger.log( + 'PerpsConnectionManager: Error during pre-warm cleanup', + error, + ); + } + }); + + // Clear the array + this.prewarmCleanups = []; + DevLogger.log('PerpsConnectionManager: Pre-warm cleanup complete'); + } + getConnectionState() { return { isConnected: this.isConnected, diff --git a/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts b/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts index eca66233651..77183abc8c6 100644 --- a/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts +++ b/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts @@ -4,6 +4,7 @@ import { adaptOrderToSDK, + adaptOrderFromSDK, adaptPositionFromSDK, adaptMarketFromSDK, adaptAccountStateFromSDK, @@ -19,6 +20,7 @@ import type { SpotClearinghouseState, } from '@deeeed/hyperliquid-node20/esm/src/types/info/accounts'; import type { PerpsUniverse } from '@deeeed/hyperliquid-node20/esm/src/types/info/assets'; +import type { FrontendOrder } from '@deeeed/hyperliquid-node20/esm/src/types/info/orders'; import { SpotBalance } from '@deeeed/hyperliquid-node20'; // Mock the isHexString utility @@ -125,6 +127,398 @@ describe('hyperLiquidAdapter', () => { }); }); + describe('adaptOrderFromSDK', () => { + it('should convert basic buy order from SDK', () => { + const frontendOrder: FrontendOrder = { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result).toEqual({ + orderId: '12345', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + size: '0.5', + originalSize: '1.0', + price: '50000', + filledSize: '0.5', + remainingSize: '0.5', + status: 'open', + timestamp: 1234567890000, + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: false, + }); + }); + + it('should convert sell order from SDK', () => { + const frontendOrder: FrontendOrder = { + oid: 54321, + coin: 'ETH', + side: 'A', + sz: '2.0', + origSz: '2.0', + limitPx: '3000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: true, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result).toEqual({ + orderId: '54321', + symbol: 'ETH', + side: 'sell', + orderType: 'limit', + size: '2.0', + originalSize: '2.0', + price: '3000', + filledSize: '0', + remainingSize: '2.0', + status: 'open', + timestamp: 1234567890000, + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: true, + }); + }); + + it('should handle market order', () => { + const frontendOrder: FrontendOrder = { + oid: 99999, + coin: 'SOL', + side: 'B', + sz: '10', + origSz: '10', + orderType: 'Market', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + limitPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result).toEqual({ + orderId: '99999', + symbol: 'SOL', + side: 'buy', + orderType: 'market', + size: '10', + originalSize: '10', + price: '0', + filledSize: '0', + remainingSize: '10', + status: 'open', + timestamp: 1234567890000, + detailedOrderType: 'Market', + isTrigger: false, + reduceOnly: false, + }); + }); + + it('should handle trigger order with triggerPx', () => { + const frontendOrder: FrontendOrder = { + oid: 11111, + coin: 'AVAX', + side: 'A', + sz: '5', + origSz: '5', + triggerPx: '25.50', + orderType: 'Stop Market', + timestamp: 1234567890000, + isTrigger: true, + reduceOnly: true, + triggerCondition: '', + limitPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result).toEqual({ + orderId: '11111', + symbol: 'AVAX', + side: 'sell', + orderType: 'market', + size: '5', + originalSize: '5', + price: '25.50', + filledSize: '0', + remainingSize: '5', + status: 'open', + timestamp: 1234567890000, + detailedOrderType: 'Stop Market', + isTrigger: true, + reduceOnly: true, + }); + }); + + it('should handle order with child orders (TP/SL)', () => { + const frontendOrder: FrontendOrder = { + oid: 22222, + coin: 'UNI', + side: 'B', + sz: '100', + origSz: '100', + limitPx: '10', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + isPositionTpsl: false, + tif: null, + cloid: null, + children: [ + { + oid: 22223, + coin: 'UNI', + side: 'A', + sz: '100', + origSz: '100', + triggerPx: '12', + orderType: 'Take Profit Market', + timestamp: 1234567890001, + isTrigger: true, + reduceOnly: true, + triggerCondition: '', + limitPx: '', + children: [], + isPositionTpsl: true, + tif: null, + cloid: null, + }, + { + oid: 22224, + coin: 'UNI', + side: 'A', + sz: '100', + origSz: '100', + triggerPx: '8', + orderType: 'Stop Market', // 'Stop Loss' is not a valid OrderType + timestamp: 1234567890002, + isTrigger: true, + reduceOnly: true, + triggerCondition: '', + limitPx: '', + children: [], + isPositionTpsl: true, + tif: null, + cloid: null, + }, + ], + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result).toEqual({ + orderId: '22222', + symbol: 'UNI', + side: 'buy', + orderType: 'limit', + size: '100', + originalSize: '100', + price: '10', + filledSize: '0', + remainingSize: '100', + status: 'open', + timestamp: 1234567890000, + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: false, + takeProfitPrice: '12', + stopLossPrice: '8', + }); + }); + + it('should handle partially filled order', () => { + const frontendOrder: FrontendOrder = { + oid: 33333, + coin: 'LINK', + side: 'B', + sz: '30', + origSz: '100', + limitPx: '15', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result).toEqual({ + orderId: '33333', + symbol: 'LINK', + side: 'buy', + orderType: 'limit', + size: '30', + originalSize: '100', + price: '15', + filledSize: '70', // 100 - 30 + remainingSize: '30', + status: 'open', + timestamp: 1234567890000, + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: false, + }); + }); + + it('should handle order without origSz', () => { + const frontendOrder: FrontendOrder = { + oid: 44444, + coin: 'MATIC', + side: 'A', + sz: '500', + limitPx: '1.2', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + origSz: '500', + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result).toEqual({ + orderId: '44444', + symbol: 'MATIC', + side: 'sell', + orderType: 'limit', + size: '500', + originalSize: '500', // Uses sz as default + price: '1.2', + filledSize: '0', + remainingSize: '500', + status: 'open', + timestamp: 1234567890000, + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: false, + }); + }); + + it('should determine order type from orderType string', () => { + const frontendOrder: FrontendOrder = { + oid: 55555, + coin: 'DOT', + side: 'B', + sz: '20', + origSz: '20', + orderType: 'Limit', // 'Limit at 5.5' is not a valid OrderType + limitPx: '5.5', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result.orderType).toBe('limit'); + expect(result.price).toBe('5.5'); + }); + + it('should handle child order with limitPx instead of triggerPx', () => { + const frontendOrder: FrontendOrder = { + oid: 66666, + coin: 'ADA', + side: 'B', + sz: '1000', + origSz: '1000', + limitPx: '0.5', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + isPositionTpsl: false, + tif: null, + cloid: null, + children: [ + { + oid: 66667, + coin: 'ADA', + side: 'A', + sz: '1000', + origSz: '1000', + limitPx: '0.6', // Using limitPx instead of triggerPx + orderType: 'Take Profit Limit', + timestamp: 1234567890001, + isTrigger: true, + reduceOnly: true, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: true, + tif: null, + cloid: null, + }, + ], + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result.takeProfitPrice).toBe('0.6'); + expect(result.stopLossPrice).toBeUndefined(); + }); + }); + describe('adaptPositionFromSDK', () => { it('should convert asset position correctly', () => { const assetPosition: AssetPosition = { diff --git a/app/components/UI/Perps/utils/hyperLiquidAdapter.ts b/app/components/UI/Perps/utils/hyperLiquidAdapter.ts index 7dfca72f804..01272dc3066 100644 --- a/app/components/UI/Perps/utils/hyperLiquidAdapter.ts +++ b/app/components/UI/Perps/utils/hyperLiquidAdapter.ts @@ -5,10 +5,12 @@ import type { SpotClearinghouseState, } from '@deeeed/hyperliquid-node20/esm/src/types/info/accounts'; import type { PerpsUniverse } from '@deeeed/hyperliquid-node20/esm/src/types/info/assets'; +import type { FrontendOrder } from '@deeeed/hyperliquid-node20/esm/src/types/info/orders'; import { isHexString } from '@metamask/utils'; import type { AccountState, MarketInfo, + Order, OrderParams as PerpsOrderParams, Position, } from '../controllers/types'; @@ -84,6 +86,90 @@ export function adaptPositionFromSDK(assetPosition: AssetPosition): Position { }; } +/** + * Transform HyperLiquid SDK order to MetaMask Perps API format + * Handles both REST API responses (FrontendOrder) and WebSocket data formats + * @param rawOrder - Raw order data from HyperLiquid SDK (frontendOpenOrders or webData2) + * @returns MetaMask Perps API order object + */ +export function adaptOrderFromSDK(rawOrder: FrontendOrder): Order { + // Extract basic fields with appropriate conversions + const orderId = rawOrder.oid.toString(); + const symbol = rawOrder.coin; + + // Convert side: HyperLiquid uses 'B' for Buy and 'A' for Ask (Sell) + const side: 'buy' | 'sell' = rawOrder.side === 'B' ? 'buy' : 'sell'; + + // Get detailed order type from API + const detailedOrderType = rawOrder.orderType; + + // Determine if this is a trigger order (TP/SL) + const isTrigger = rawOrder.isTrigger; + const reduceOnly = rawOrder.reduceOnly; + + // Determine basic order type + let orderType: 'limit' | 'market' = 'market'; + if (detailedOrderType.toLowerCase().includes('limit') || rawOrder.limitPx) { + orderType = 'limit'; + } + + // For trigger orders (TP/SL), use triggerPx as the price + const price = rawOrder.limitPx || rawOrder.triggerPx || '0'; + + // Sizes + const size = rawOrder.sz; + const originalSize = rawOrder.origSz || size; + + // Calculate filled and remaining size + const currentSize = parseFloat(size); + const origSize = parseFloat(originalSize); + const filledSize = origSize - currentSize; + + // Check for TP/SL in child orders (REST API feature) + let takeProfitPrice: string | undefined; + let stopLossPrice: string | undefined; + + if (rawOrder.children && rawOrder.children.length > 0) { + rawOrder.children.forEach((child) => { + if (child.isTrigger && child.orderType) { + if (child.orderType.includes('Take Profit')) { + takeProfitPrice = child.triggerPx || child.limitPx; + } else if (child.orderType.includes('Stop')) { + stopLossPrice = child.triggerPx || child.limitPx; + } + } + }); + } + + // Build the order object + const order: Order = { + orderId, + symbol, + side, + orderType, + size, + originalSize, + price, + filledSize: filledSize.toString(), + remainingSize: size, + status: 'open' as const, // All orders from frontendOpenOrders/webData2 are open + timestamp: rawOrder.timestamp, + detailedOrderType, + isTrigger, + reduceOnly, + }; + + // Add optional fields if they exist + if (takeProfitPrice) { + order.takeProfitPrice = takeProfitPrice; + } + if (stopLossPrice) { + order.stopLossPrice = stopLossPrice; + } + + return order; +} + /** * Transform SDK market info to MetaMask Perps API format * @param sdkMarket - Market metadata from HyperLiquid SDK diff --git a/app/components/UI/Perps/utils/marketDataTransform.test.ts b/app/components/UI/Perps/utils/marketDataTransform.test.ts index 2c1d04c31a7..c93c82a8f10 100644 --- a/app/components/UI/Perps/utils/marketDataTransform.test.ts +++ b/app/components/UI/Perps/utils/marketDataTransform.test.ts @@ -10,7 +10,11 @@ import { formatVolume, HyperLiquidMarketData, } from './marketDataTransform'; -import { AllMids, PerpsAssetCtx } from '@deeeed/hyperliquid-node20'; +import { + AllMids, + PerpsAssetCtx, + PredictedFunding, +} from '@deeeed/hyperliquid-node20'; // Helper function to create mock asset context with all required properties const createMockAssetCtx = (overrides: Record = {}) => ({ @@ -200,6 +204,97 @@ describe('marketDataTransform', () => { expect(result[0].volume).toBe('$1B'); // Has context expect(result[1].volume).toBe('$0'); // No context }); + + it('handles predicted funding data correctly', () => { + // Arrange + const hyperLiquidData: HyperLiquidMarketData = { + universe: [mockUniverseAsset], + assetCtxs: [mockAssetCtx], + allMids: mockAllMids, + predictedFundings: [ + [ + 'BTC', + [ + [ + 'HyperLiquid', + { + fundingRate: '0.001', + nextFundingTime: 1234567890000, + fundingIntervalHours: 8, + }, + ], + ], + ], + ], + }; + + // Act + const result = transformMarketData(hyperLiquidData); + + // Assert + expect(result[0].nextFundingTime).toBe(1234567890000); + expect(result[0].fundingIntervalHours).toBe(8); + }); + + it('handles malformed funding data without crashing', () => { + // Arrange - Test various edge cases that could cause destructuring errors + const testCases = [ + // Case 1: fundingData[1][0] is not an array + { + predictedFundings: [['BTC', ['not-an-array']]], + }, + // Case 2: fundingData[1][0] is an array with less than 2 elements + { + predictedFundings: [['BTC', [['HyperLiquid']]]], + }, + // Case 3: fundingData[1][0] is null + { + predictedFundings: [['BTC', [null]]], + }, + // Case 4: fundingData[1] is empty array + { + predictedFundings: [['BTC', []]], + }, + // Case 5: fundingData[1] is undefined + { + predictedFundings: [['BTC', undefined]], + }, + ]; + + testCases.forEach((testCase) => { + const hyperLiquidData: HyperLiquidMarketData = { + universe: [mockUniverseAsset], + assetCtxs: [mockAssetCtx], + allMids: mockAllMids, + predictedFundings: testCase.predictedFundings as PredictedFunding[], + }; + + // Act & Assert - should not throw + expect(() => { + const result = transformMarketData(hyperLiquidData); + // Should return result without funding data + expect(result[0].nextFundingTime).toBeUndefined(); + expect(result[0].fundingIntervalHours).toBeUndefined(); + }).not.toThrow(); + }); + }); + + it('handles missing predicted funding gracefully', () => { + // Arrange + const hyperLiquidData: HyperLiquidMarketData = { + universe: [mockUniverseAsset], + assetCtxs: [mockAssetCtx], + allMids: mockAllMids, + // No predictedFundings field + }; + + // Act + const result = transformMarketData(hyperLiquidData); + + // Assert + expect(result[0].nextFundingTime).toBeUndefined(); + expect(result[0].fundingIntervalHours).toBeUndefined(); + }); }); describe('formatPrice', () => { diff --git a/app/components/UI/Perps/utils/marketDataTransform.ts b/app/components/UI/Perps/utils/marketDataTransform.ts index d8d90d2a77b..9a7585d13f1 100644 --- a/app/components/UI/Perps/utils/marketDataTransform.ts +++ b/app/components/UI/Perps/utils/marketDataTransform.ts @@ -3,6 +3,7 @@ import type { PerpsUniverse, PerpsAssetCtx, AllMids, + PredictedFunding, } from '@deeeed/hyperliquid-node20'; /** @@ -12,6 +13,7 @@ export interface HyperLiquidMarketData { universe: PerpsUniverse[]; assetCtxs: PerpsAssetCtx[]; allMids: AllMids; + predictedFundings?: PredictedFunding[]; } /** @@ -22,7 +24,7 @@ export interface HyperLiquidMarketData { export function transformMarketData( hyperLiquidData: HyperLiquidMarketData, ): PerpsMarketData[] { - const { universe, assetCtxs, allMids } = hyperLiquidData; + const { universe, assetCtxs, allMids, predictedFundings } = hyperLiquidData; return universe.map((asset) => { const symbol = asset.name; @@ -57,6 +59,30 @@ export function transformMarketData( // Format volume (dayNtlVlm is daily notional volume) const volume = assetCtx ? parseFloat(assetCtx.dayNtlVlm) : 0; + // Extract funding time data if available + let nextFundingTime: number | undefined; + let fundingIntervalHours: number | undefined; + + if (predictedFundings) { + // Find the funding data for this specific symbol + const fundingData = predictedFundings.find( + ([assetSymbol]) => assetSymbol === symbol, + ); + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain + if (fundingData && fundingData[1] && fundingData[1].length > 0) { + // Get the first exchange's funding data (usually HyperLiquid itself) + // Safely check if the first element is an array with at least 2 elements + const firstExchange = fundingData[1][0]; + if (Array.isArray(firstExchange) && firstExchange.length >= 2) { + const exchangeData = firstExchange[1]; + if (exchangeData) { + nextFundingTime = exchangeData.nextFundingTime; + fundingIntervalHours = exchangeData.fundingIntervalHours; + } + } + } + } + return { symbol, name: symbol, // HyperLiquid uses symbol as name @@ -67,6 +93,8 @@ export function transformMarketData( ? '0.00%' : formatPercentage(change24hPercent), volume: isNaN(volume) ? '$0' : formatVolume(volume), + nextFundingTime, + fundingIntervalHours, }; }); } diff --git a/app/components/UI/Perps/utils/marketUtils.test.ts b/app/components/UI/Perps/utils/marketUtils.test.ts index 89dd11cf2ea..b4c39eefd9e 100644 --- a/app/components/UI/Perps/utils/marketUtils.test.ts +++ b/app/components/UI/Perps/utils/marketUtils.test.ts @@ -65,6 +65,91 @@ describe('marketUtils', () => { const result = calculateFundingCountdown(); expect(result).toBe('00:01:05'); // 1 minute 5 seconds until 8:00 }); + + it('should use specific next funding time when provided', () => { + const mockDate = new Date('2024-01-01T12:00:00.000Z'); + jest.setSystemTime(mockDate); + + // Next funding time is in 2 hours 30 minutes + const nextFundingTime = mockDate.getTime() + (2 * 60 + 30) * 60 * 1000; + + const result = calculateFundingCountdown({ nextFundingTime }); + expect(result).toBe('02:30:00'); + }); + + it('should use specific next funding time with seconds', () => { + const mockDate = new Date('2024-01-01T12:00:00.000Z'); + jest.setSystemTime(mockDate); + + // Next funding time is in 1 hour 15 minutes 45 seconds + const nextFundingTime = + mockDate.getTime() + (1 * 60 * 60 + 15 * 60 + 45) * 1000; + + const result = calculateFundingCountdown({ nextFundingTime }); + expect(result).toBe('01:15:45'); + }); + + it('should handle expired specific funding time', () => { + const mockDate = new Date('2024-01-01T12:00:00.000Z'); + jest.setSystemTime(mockDate); + + // Next funding time is in the past + const nextFundingTime = mockDate.getTime() - 1000; + + const result = calculateFundingCountdown({ nextFundingTime }); + // Falls back to default calculation when specific time is expired + expect(result).toBe('04:00:00'); // 4 hours until 16:00 + }); + + it('should handle edge case at 59 seconds', () => { + // Set time to 07:59:01 UTC (59 seconds before funding) + const mockDate = new Date('2024-01-01T07:59:01.000Z'); + jest.setSystemTime(mockDate); + + const result = calculateFundingCountdown(); + expect(result).toBe('00:00:59'); + }); + + it('should handle edge case with 60 seconds exactly', () => { + // Set time to 07:59:00 UTC (60 seconds before funding) + const mockDate = new Date('2024-01-01T07:59:00.000Z'); + jest.setSystemTime(mockDate); + + const result = calculateFundingCountdown(); + expect(result).toBe('00:01:00'); + }); + + it('should handle different UTC hours correctly', () => { + // Test each hour of the day + const testCases = [ + { hour: 0, expected: '08:00:00' }, // 00:00 -> 08:00 + { hour: 4, expected: '04:00:00' }, // 04:00 -> 08:00 + { hour: 8, expected: '08:00:00' }, // 08:00 -> 16:00 + { hour: 12, expected: '04:00:00' }, // 12:00 -> 16:00 + { hour: 16, expected: '08:00:00' }, // 16:00 -> 00:00 (next day) + { hour: 20, expected: '04:00:00' }, // 20:00 -> 00:00 (next day) + ]; + + testCases.forEach(({ hour, expected }) => { + const mockDate = new Date( + `2024-01-01T${hour.toString().padStart(2, '0')}:00:00.000Z`, + ); + jest.setSystemTime(mockDate); + + const result = calculateFundingCountdown(); + expect(result).toBe(expected); + }); + }); + + it('should handle time with custom funding interval', () => { + const mockDate = new Date('2024-01-01T10:00:00.000Z'); + jest.setSystemTime(mockDate); + + // Custom 4 hour funding interval (not used in default calculation) + const result = calculateFundingCountdown({ fundingIntervalHours: 4 }); + // Still uses default calculation when no nextFundingTime provided + expect(result).toBe('06:00:00'); // 6 hours until 16:00 + }); }); describe('calculate24hHighLow', () => { diff --git a/app/components/UI/Perps/utils/marketUtils.ts b/app/components/UI/Perps/utils/marketUtils.ts index 4dbaa08a49d..f78762016b0 100644 --- a/app/components/UI/Perps/utils/marketUtils.ts +++ b/app/components/UI/Perps/utils/marketUtils.ts @@ -1,11 +1,46 @@ import type { CandleData, CandleStick } from '../types'; +interface FundingCountdownParams { + /** + * Next funding time in milliseconds since epoch (optional, market-specific) + */ + nextFundingTime?: number; + /** + * Funding interval in hours (optional, market-specific) + * Default is 8 hours for HyperLiquid + */ + fundingIntervalHours?: number; +} + /** * Calculate the time until the next funding period - * HyperLiquid has 8-hour funding periods at 00:00, 08:00, and 16:00 UTC + * Supports market-specific funding times when provided + * Falls back to default HyperLiquid 8-hour periods at 00:00, 08:00, and 16:00 UTC */ -export const calculateFundingCountdown = (): string => { +export const calculateFundingCountdown = ( + params?: FundingCountdownParams, +): string => { const now = new Date(); + const nowMs = now.getTime(); + + // If we have a specific next funding time, use it + if (params?.nextFundingTime && params.nextFundingTime > nowMs) { + const msUntilFunding = params.nextFundingTime - nowMs; + const totalSeconds = Math.floor(msUntilFunding / 1000); + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + // Format as HH:MM:SS + const formattedHours = String(hours).padStart(2, '0'); + const formattedMinutes = String(minutes).padStart(2, '0'); + const formattedSeconds = String(seconds).padStart(2, '0'); + + return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`; + } + + // Fall back to default calculation for HyperLiquid (8-hour periods) const utcHour = now.getUTCHours(); const utcMinutes = now.getUTCMinutes(); const utcSeconds = now.getUTCSeconds(); diff --git a/locales/languages/en.json b/locales/languages/en.json index bd02864c896..46de305af26 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1150,6 +1150,9 @@ "available_balance": "Available Balance", "margin_used": "Margin Used", "total_unrealized_pnl": "Total Unrealized P&L" + }, + "tpsl": { + "update_success": "TP/SL updated successfully" } }, "markets": {