diff --git a/package.json b/package.json index 73b5d83606..0bbe8ac8d7 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@react-types/shared": "^3.32.1", "@react-types/table": "^3.13.4", "@reduxjs/toolkit": "^2.2.5", + "@scure/bip39": "^2.0.1", "@skip-go/client": "1.5.8", "@solana/web3.js": "^1.93.0", "@statsig/js-client": "1.4.0", @@ -116,9 +117,11 @@ "@visx/xychart": "^3.1.2", "@wagmi/core": "^2.16.3", "bignumber.js": "^9.1.1", + "bs58": "^6.0.0", "cmdk": "^0.2.0", "crypto-js": "^4.1.1", "cuer": "^0.0.2", + "ed25519-hd-key": "^1.3.0", "export-to-csv": "^1.2.3", "fast-json-stable-stringify": "^2.1.0", "graz": "^0.1.19", @@ -156,6 +159,7 @@ "@ryoppippi/unplugin-typia": "npm:@jsr/ryoppippi__unplugin-typia@^1.1.0", "@testing-library/webdriverio": "^3.2.1", "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@types/bs58": "^5.0.0", "@types/color": "^3.0.3", "@types/crypto-js": "^4.1.1", "@types/luxon": "^3.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecc09710d2..265fc036fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ dependencies: '@reduxjs/toolkit': specifier: ^2.2.5 version: 2.9.0(react-redux@9.2.0)(react@18.3.1) + '@scure/bip39': + specifier: ^2.0.1 + version: 2.0.1 '@skip-go/client': specifier: 1.5.8 version: 1.5.8(@solana/web3.js@1.98.4)(viem@2.38.0) @@ -215,6 +218,9 @@ dependencies: bignumber.js: specifier: ^9.1.1 version: 9.3.1 + bs58: + specifier: ^6.0.0 + version: 6.0.0 cmdk: specifier: ^0.2.0 version: 0.2.1(@types/react@18.3.26)(react-dom@18.3.1)(react@18.3.1) @@ -224,6 +230,9 @@ dependencies: cuer: specifier: ^0.0.2 version: 0.0.2(react-dom@18.3.1)(react@18.3.1)(typescript@5.9.3) + ed25519-hd-key: + specifier: ^1.3.0 + version: 1.3.0 export-to-csv: specifier: ^1.2.3 version: 1.4.0 @@ -331,6 +340,9 @@ devDependencies: '@trivago/prettier-plugin-sort-imports': specifier: ^4.3.0 version: 4.3.0(prettier@3.6.2) + '@types/bs58': + specifier: ^5.0.0 + version: 5.0.0 '@types/color': specifier: ^3.0.3 version: 3.0.6 @@ -4506,7 +4518,6 @@ packages: /@noble/hashes@2.0.1: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} - dev: true /@noble/secp256k1@1.7.1: resolution: {integrity: sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==} @@ -8894,6 +8905,10 @@ packages: resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} dev: false + /@scure/base@2.0.0: + resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} + dev: false + /@scure/bip32@1.1.5: resolution: {integrity: sha512-XyNh1rB0SkEqd3tXcXMi+Xe1fvg+kUIcoRIEujP1Jgv7DqW2r9lg3Ah0NkFaCs9sTkQAQA8kw7xiRXzENi9Rtw==} dependencies: @@ -8954,6 +8969,13 @@ packages: '@scure/base': 1.2.6 dev: false + /@scure/bip39@2.0.1: + resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} + dependencies: + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + dev: false + /@simplewebauthn/browser@9.0.1: resolution: {integrity: sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==} dependencies: @@ -9961,6 +9983,13 @@ packages: '@types/node': 22.18.8 dev: false + /@types/bs58@5.0.0: + resolution: {integrity: sha512-cAw/jKBzo98m6Xz1X5ETqymWfIMbXbu6nK15W4LQYjeHJkVqSmM5PO8Bd9KVHQJ/F4rHcSso9LcjtgCW6TGu2w==} + deprecated: This is a stub types definition. bs58 provides its own type definitions, so you do not need this installed. + dependencies: + bs58: 6.0.0 + dev: true + /@types/color-convert@2.0.4: resolution: {integrity: sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==} dependencies: @@ -13180,7 +13209,6 @@ packages: /ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} - requiresBuild: true /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} @@ -13658,7 +13686,6 @@ packages: /base-x@5.0.1: resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} - dev: false /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -13954,7 +13981,6 @@ packages: resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} dependencies: base-x: 5.0.1 - dev: false /bs58check@2.1.2: resolution: {integrity: sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==} @@ -15524,6 +15550,13 @@ packages: '@noble/hashes': 1.8.0 dev: false + /ed25519-hd-key@1.3.0: + resolution: {integrity: sha512-IWwAyiiuJQhgu3L8NaHb68eJxTu2pgCwxIBdgpLJdKpYZM46+AXePSVTr7fkNKaUOfOL4IrjEUaQvyVRIDP7fg==} + dependencies: + create-hmac: 1.1.7 + tweetnacl: 1.0.3 + dev: false + /edge-paths@3.0.5: resolution: {integrity: sha512-sB7vSrDnFa4ezWQk9nZ/n0FdpdUuC6R1EOrlU3DL+bovcNFK28rqu2emmAUjujYEJTWIgQGqgVVWUZXMnc8iWg==} engines: {node: '>=14.0.0'} @@ -16832,7 +16865,7 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.3.4 get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -21337,7 +21370,7 @@ packages: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.3.4 get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -22002,7 +22035,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.3.4 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -23521,7 +23554,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.3.4 socks: 2.8.7 transitivePeerDependencies: - supports-color diff --git a/public/configs/v1/env.json b/public/configs/v1/env.json index 43adbbeab1..8cc3317d49 100644 --- a/public/configs/v1/env.json +++ b/public/configs/v1/env.json @@ -452,7 +452,7 @@ "https://validator.v4dev.dydx.exchange" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", @@ -461,7 +461,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4dev.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -496,13 +497,14 @@ "http://dev2-validator-lb-58813722.us-east-2.elb.amazonaws.com" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "neutronValidator": "https://neutron-testnet-rpc.polkachu.com/", "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", - "geo": "https://api.dydx.exchange/v4/geo" + "geo": "https://api.dydx.exchange/v4/geo", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -537,7 +539,7 @@ "http://validator-dev3-lb-1393802013.us-east-2.elb.amazonaws.com" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", @@ -546,7 +548,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "http://dev3-faucet-lb-public-1644791410.us-east-2.elb.amazonaws.com", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -581,7 +584,7 @@ "https://validator.v4dev4.dydx.exchange" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", @@ -590,7 +593,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4dev4.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -625,13 +629,14 @@ "http://18.223.78.50:26657" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "neutronValidator": "https://neutron-testnet-rpc.polkachu.com/", "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", - "geo": "https://api.dydx.exchange/v4/geo" + "geo": "https://api.dydx.exchange/v4/geo", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -668,14 +673,14 @@ "https://validator.v4staging.dydx.exchange" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "neutronValidator": "https://neutron-testnet-rpc.polkachu.com/", "geo": "https://api.dydx.exchange/v4/geo", - "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", - "spotCandleService": "https://pp-candle-service-stag-710ee7adad29.herokuapp.com" + "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -712,13 +717,14 @@ "https://validator.v4staging.dydx.exchange" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "neutronValidator": "https://neutron-testnet-rpc.polkachu.com/", "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", - "geo": "https://api.dydx.exchange/v4/geo" + "geo": "https://api.dydx.exchange/v4/geo", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "apps": { "ios": { @@ -767,13 +773,14 @@ "https://validator-uswest1.v4staging.dydx.exchange" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "neutronValidator": "https://neutron-testnet-rpc.polkachu.com/", "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", - "geo": "https://api.dydx.exchange/v4/geo" + "geo": "https://api.dydx.exchange/v4/geo", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -809,7 +816,7 @@ "https://test-dydx-rpc.kingnodes.com" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", @@ -818,8 +825,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4testnet.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz", - "spotCandleService": "https://pp-candle-service-stag-710ee7adad29.herokuapp.com" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-staging-e2fb353831a4.herokuapp.com" }, "stakingValidators": [ "dydxvaloper1vvc9vl6z9pu0vt2y79d0ln8zp6qmpmrhxx99h4", @@ -857,7 +864,7 @@ "https://validator.v4testnet.dydx.exchange" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", @@ -866,7 +873,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4testnet.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -901,7 +909,7 @@ "https://dydx-testnet.nodefleet.org" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", @@ -910,7 +918,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4testnet.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -945,7 +954,7 @@ "https://test-dydx.kingnodes.com" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", @@ -954,7 +963,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4testnet.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -989,7 +999,7 @@ "https://dydx-rpc.liquify.com/api=8878132/dydx" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", @@ -998,7 +1008,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4testnet.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -1033,7 +1044,7 @@ "https://dydx-testnet-rpc.polkachu.com/" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", @@ -1042,7 +1053,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4testnet.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -1077,7 +1089,7 @@ "https://dydx-testnet-full-rpc.public.blastapi.io/" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", @@ -1086,7 +1098,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4testnet.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -1130,7 +1143,8 @@ "geo": "[geo endpoint for mainnet]", "geoV2": "[geo v2 endpoint for mainnet]", "stakingAPR": "[staking APR endpoint for mainnet]", - "affiliates": "[affiliates endpoint for mainnet]" + "affiliates": "[affiliates endpoint for mainnet]", + "spotApi": "[spot api endpoint for mainnet]" }, "stakingValidators": [], "featureFlags": { diff --git a/src/App.tsx b/src/App.tsx index c0064be8ad..5e88788717 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,7 +37,6 @@ import { FooterMobile } from '@/layout/Footer/FooterMobile'; import { HeaderDesktop } from '@/layout/Header/HeaderDesktop'; import { NotificationsToastArea } from '@/layout/NotificationsToastArea'; -import { testFlags } from '@/lib/testFlags'; import { parseLocationHash } from '@/lib/urlUtils'; import { config, privyConfig } from '@/lib/wagmi'; @@ -53,6 +52,7 @@ import { useAnalytics } from './hooks/useAnalytics'; import { useBreakpoints } from './hooks/useBreakpoints'; import { useCommandMenu } from './hooks/useCommandMenu'; import { useComplianceState } from './hooks/useComplianceState'; +import { useEnableSpot } from './hooks/useEnableSpot'; import { useInitializePage } from './hooks/useInitializePage'; import { useLocalStorage } from './hooks/useLocalStorage'; import { useReferralCode } from './hooks/useReferralCode'; @@ -68,6 +68,7 @@ import { TurnkeyAuthProvider } from './providers/TurnkeyAuthProvider'; import { TurnkeyWalletProvider } from './providers/TurnkeyWalletProvider'; import { persistor } from './state/_store'; import { setOnboardedThisSession } from './state/account'; +import { setCurrentPath } from './state/app'; import { appQueryClient } from './state/appQueryClient'; import { useAppDispatch, useAppSelector } from './state/appTypes'; import { AppTheme, setAppThemeSetting } from './state/appUiConfigs'; @@ -106,12 +107,19 @@ const Content = () => { const { chainTokenLabel } = useTokenConfigs(); const location = useLocation(); + const dispatch = useAppDispatch(); const isShowingHeader = isNotTablet; const isShowingFooter = useShouldShowFooter(); const abDefaultToMarkets = useCustomFlagValue(CustomFlags.abDefaultToMarkets); const isSimpleUi = useSimpleUiEnabled(); const { showComplianceBanner } = useComplianceState(); const isSimpleUiUserMenuOpen = useAppSelector(getIsUserMenuOpen); + const isSpotEnabled = useEnableSpot(); + + // Track current path in Redux for conditional polling + useEffect(() => { + dispatch(setCurrentPath(location.pathname)); + }, [location.pathname, dispatch]); const pathFromHash = useMemo(() => { if (location.hash === '') { @@ -201,7 +209,9 @@ const Content = () => { } /> - {testFlags.spot && } />} + {isSpotEnabled && ( + } /> + )} } /> diff --git a/src/bonsai/forms/spot.ts b/src/bonsai/forms/spot.ts new file mode 100644 index 0000000000..caac4cbdab --- /dev/null +++ b/src/bonsai/forms/spot.ts @@ -0,0 +1,299 @@ +import { LAMPORTS_PER_SOL } from '@solana/web3.js'; + +import { MIN_SOL_RESERVE } from '@/constants/spot'; + +import { SpotApiCreateTransactionRequest, SpotApiSide, SpotApiTradeRoute } from '@/clients/spotApi'; +import { calc, mapIfPresent } from '@/lib/do'; +import { AttemptNumber, MustNumber } from '@/lib/numbers'; + +import { createForm, createVanillaReducer } from '../lib/forms'; +import { ErrorType, simpleValidationError, ValidationError } from '../lib/validationErrors'; + +export enum SpotSide { + BUY = 'BUY', + SELL = 'SELL', +} + +export enum SpotBuyInputType { + USD = 'USD', + SOL = 'SOL', +} + +export enum SpotSellInputType { + PERCENT = 'PERCENT', + USD = 'USD', +} + +export interface SpotFormState { + side: SpotSide; + buyInputType: SpotBuyInputType; + sellInputType: SpotSellInputType; + size: string; +} + +const initialState: SpotFormState = { + side: SpotSide.BUY, + buyInputType: SpotBuyInputType.USD, + sellInputType: SpotSellInputType.PERCENT, + size: '', +}; + +const reducer = createVanillaReducer({ + initialState, + actions: { + setSide: (state, side: SpotSide) => ({ + ...state, + side, + size: '', // Clear size when switching side + }), + setBuyInputType: (state, buyInputType: SpotBuyInputType) => ({ + ...state, + buyInputType, + }), + setSellInputType: (state, sellInputType: SpotSellInputType) => ({ + ...state, + sellInputType, + }), + setSize: (state, size: string) => ({ + ...state, + size, + }), + reset: () => initialState, + }, +}); + +export interface SpotFormInputData { + tokenPriceUsd: number | undefined; + solPriceUsd: number | undefined; + userSolBalance: number | undefined; + userTokenBalance: number | undefined; + tokenMint: string | undefined; + decimals: number | undefined; + pairAddress: string | undefined; + tradeRoute: SpotApiTradeRoute | undefined; + solanaAddress: string | undefined; + isReady: boolean; + isAsyncDataReady: boolean; + isRestReady: boolean; +} + +export interface SpotAmounts { + sol: number; + token: number; + usd: number; + percent: number | undefined; +} + +export interface SpotSummaryData { + amounts: SpotAmounts | undefined; + payload: SpotApiCreateTransactionRequest | undefined; +} + +function calculateSummary(state: SpotFormState, inputData: SpotFormInputData): SpotSummaryData { + const parsedSize = AttemptNumber(state.size); + + const amounts: SpotAmounts | undefined = calc(() => + mapIfPresent( + parsedSize, + inputData.tokenPriceUsd, + inputData.solPriceUsd, + (size, tokenUsdPrice, solUsdPrice): SpotAmounts => { + if (state.side === SpotSide.BUY) { + if (state.buyInputType === SpotBuyInputType.SOL) { + const sol = size; + const usd = sol * solUsdPrice; + const token = usd / tokenUsdPrice; + return { sol, usd, token, percent: undefined }; + } + + const usd = size; + const sol = usd / solUsdPrice; + const token = usd / tokenUsdPrice; + return { sol, usd, token, percent: undefined }; + } + + if (state.sellInputType === SpotSellInputType.USD) { + const usd = size; + const sol = usd / solUsdPrice; + const token = usd / tokenUsdPrice; + const percent = + inputData.userTokenBalance != null && inputData.userTokenBalance > 0 + ? (token / inputData.userTokenBalance) * 100 + : undefined; + return { sol, usd, token, percent }; + } + + const percent = size; + const token = (percent / 100) * (inputData.userTokenBalance ?? 0); + const usd = token * tokenUsdPrice; + const sol = usd / solUsdPrice; + return { sol, usd, token, percent }; + } + ) + ); + + const payload = calc((): SpotApiCreateTransactionRequest | undefined => { + if (state.side === SpotSide.BUY) { + return mapIfPresent( + amounts?.sol, + inputData.tokenMint, + inputData.solanaAddress, + inputData.pairAddress, + inputData.tradeRoute, + (solAmount, tokenMint, solanaAddress, pairAddress, tradeRoute) => { + const inAmount = Math.floor(solAmount * LAMPORTS_PER_SOL).toString(); + return { + account: solanaAddress, + tokenMint, + side: SpotApiSide.BUY, + inAmount, + pool: pairAddress, + tradeRoute, + }; + } + ); + } + + return mapIfPresent( + amounts?.token, + inputData.tokenMint, + inputData.solanaAddress, + inputData.pairAddress, + inputData.tradeRoute, + inputData.decimals, + (tokenAmount, tokenMint, solanaAddress, pairAddress, tradeRoute, decimals) => { + const inAmount = Math.floor(tokenAmount * decimals).toString(); + return { + account: solanaAddress, + tokenMint, + side: SpotApiSide.SELL, + inAmount, + pool: pairAddress, + tradeRoute, + }; + } + ); + }); + + return { amounts, payload }; +} + +export function getErrors( + state: SpotFormState, + inputData: SpotFormInputData, + summary: SpotSummaryData +): ValidationError[] { + const validationErrors: ValidationError[] = []; + + const parsedSize = AttemptNumber(state.size); + + if (parsedSize == null || parsedSize <= 0) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_AMOUNT_EMPTY', + type: ErrorType.error, + fields: ['size'], + titleFallback: 'Enter Amount', + }) + ); + return validationErrors; + } + + if ( + inputData.userSolBalance == null || + inputData.tokenPriceUsd == null || + inputData.solPriceUsd == null || + inputData.tokenMint == null || + inputData.pairAddress == null || + inputData.tradeRoute == null || + inputData.solanaAddress == null || + inputData.decimals == null || + !inputData.isAsyncDataReady + ) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_MISSING_DATA', + type: ErrorType.error, + titleFallback: 'Missing Data', + textFallback: 'Market data is loading. Please wait...', + }) + ); + return validationErrors; + } + + if (state.side === SpotSide.BUY) { + const requiredSol = MustNumber(summary.amounts?.sol); + const availableSol = inputData.userSolBalance; + + if (requiredSol > availableSol) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_INSUFFICIENT_SOL', + type: ErrorType.error, + fields: ['size'], + titleFallback: 'Insufficient Balance', + textFallback: 'Insufficient SOL balance for this purchase', + }) + ); + } + + if (availableSol - requiredSol < MIN_SOL_RESERVE) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_LOW_SOL_FOR_FEES', + type: ErrorType.warning, + titleFallback: 'Low SOL Balance', + textFallback: + 'After this trade, your remaining SOL may be insufficient for future transaction fees', + }) + ); + } + } else { + const requiredToken = MustNumber(summary.amounts?.token); + const availableToken = MustNumber(inputData.userTokenBalance); + const sellPercentage = MustNumber(summary.amounts?.percent); + + if (requiredToken > availableToken) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_INSUFFICIENT_TOKEN', + type: ErrorType.error, + fields: ['size'], + titleFallback: 'Insufficient Balance', + textFallback: 'Insufficient token balance for this trade', + }) + ); + } + + if (state.sellInputType === SpotSellInputType.PERCENT && sellPercentage > 100) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_PERCENT_TOO_HIGH', + type: ErrorType.error, + fields: ['size'], + titleFallback: 'Invalid Percent', + textFallback: 'Percent must be 100 or less', + }) + ); + } + + if (inputData.userSolBalance < MIN_SOL_RESERVE) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_LOW_SOL_FOR_FEES', + type: ErrorType.warning, + titleFallback: 'Low SOL Balance', + textFallback: 'Your SOL balance may be insufficient for transaction fees', + }) + ); + } + } + + return validationErrors; +} + +export const SpotFormFns = createForm({ + reducer, + calculateSummary, + getErrors, +}); diff --git a/src/bonsai/lib/validationErrors.ts b/src/bonsai/lib/validationErrors.ts index f2c5e288ce..514e419c6a 100644 --- a/src/bonsai/lib/validationErrors.ts +++ b/src/bonsai/lib/validationErrors.ts @@ -20,8 +20,9 @@ export interface ErrorResources { } export interface ErrorString { - stringKey: string; + stringKey?: string; params?: { [key: string]: ErrorParam }; + fallback?: string; } export interface ErrorParam { @@ -74,6 +75,8 @@ interface SimpleValidationErrorParams { fields?: string[]; titleKey?: string; textKey?: string; + titleFallback?: string; + textFallback?: string; titleParams?: { [key: string]: ErrorParam }; textParams?: { [key: string]: ErrorParam }; learnMoreUrlKey?: keyof (typeof LINKS_CONFIG_MAP)[keyof typeof LINKS_CONFIG_MAP]; @@ -85,6 +88,8 @@ export function simpleValidationError({ fields, titleKey, textKey, + titleFallback, + textFallback, textParams, titleParams, learnMoreUrlKey, @@ -98,18 +103,22 @@ export function simpleValidationError({ linkText: null, resources: { learnMoreUrlKey, - title: titleKey - ? { - stringKey: titleKey, - params: titleParams, - } - : undefined, - text: textKey - ? { - stringKey: textKey, - params: textParams, - } - : undefined, + title: + titleKey != null || titleFallback != null + ? { + stringKey: titleKey, + params: titleParams, + fallback: titleFallback, + } + : undefined, + text: + textKey != null || textFallback != null + ? { + stringKey: textKey, + params: textParams, + fallback: textFallback, + } + : undefined, action: null, }, }; diff --git a/src/bonsai/ontology.ts b/src/bonsai/ontology.ts index e2dca96bbb..a0cc7c25b2 100644 --- a/src/bonsai/ontology.ts +++ b/src/bonsai/ontology.ts @@ -12,10 +12,17 @@ import { IndexerWsTradesUpdateObject } from '@/types/indexer/indexerManual'; import { type RootState } from '@/state/_store'; import { getCurrentMarketId } from '@/state/currentMarketSelectors'; +import { SpotApiPortfolioTradesResponse, SpotApiTokenInfoObject } from '@/clients/spotApi'; +import { + SpotApiWsWalletBalanceObject, + SpotApiWsWalletPositionObject, + SpotApiWsWalletPositionsUpdate, +} from '@/lib/streaming/walletPositionsStreaming'; import { RecordValueType } from '@/lib/typeUtils'; import { HistoricalFundingObject } from './calculators/funding'; import { AdjustIsolatedMarginFormFns } from './forms/adjustIsolatedMargin'; +import { SpotFormFns } from './forms/spot'; import { TradeFormFns } from './forms/trade/trade'; import { TransferFormFns } from './forms/transfers'; import { TriggerOrdersFormFns } from './forms/triggers/triggers'; @@ -89,6 +96,20 @@ import { selectCurrentMarketOrderbook, } from './selectors/orderbook'; import { selectRewardsSummary } from './selectors/rewards'; +import { + selectSpotBalances, + selectSpotPortfolioTrades, + selectSpotPortfolioTradesLoading, + selectSpotPositions, + selectSpotSolPrice, + selectSpotSolPriceLoading, + selectSpotTokenMetadata, + selectSpotTokenMetadataLoading, + selectSpotTokenPrice, + selectSpotTokenPriceLoading, + selectSpotWalletPositions, + selectSpotWalletPositionsLoading, +} from './selectors/spot'; import { selectAllMarketSummaries, selectAllMarketSummariesLoading, @@ -216,6 +237,30 @@ interface BonsaiCoreShape { }; compliance: { data: BasicSelector; loading: BasicSelector }; rewardParams: { data: BasicSelector }; + spot: { + solPrice: { + data: BasicSelector; + loading: BasicSelector; + }; + tokenPrice: { + data: BasicSelector; + loading: BasicSelector; + }; + tokenMetadata: { + data: BasicSelector; + loading: BasicSelector; + }; + walletPositions: { + data: BasicSelector; + loading: BasicSelector; + positions: BasicSelector; + tokenBalances: BasicSelector; + }; + portfolioTrades: { + data: BasicSelector; + loading: BasicSelector; + }; + }; } export const BonsaiCore: BonsaiCoreShape = { @@ -299,6 +344,30 @@ export const BonsaiCore: BonsaiCoreShape = { }, compliance: { data: selectCompliance, loading: selectComplianceLoading }, rewardParams: { data: selectRewardsSummary }, + spot: { + solPrice: { + data: selectSpotSolPrice, + loading: selectSpotSolPriceLoading, + }, + tokenPrice: { + data: selectSpotTokenPrice, + loading: selectSpotTokenPriceLoading, + }, + tokenMetadata: { + data: selectSpotTokenMetadata, + loading: selectSpotTokenMetadataLoading, + }, + walletPositions: { + data: selectSpotWalletPositions, + loading: selectSpotWalletPositionsLoading, + positions: selectSpotPositions, + tokenBalances: selectSpotBalances, + }, + portfolioTrades: { + data: selectSpotPortfolioTrades, + loading: selectSpotPortfolioTradesLoading, + }, + }, }; interface BonsaiRawShape { @@ -456,4 +525,5 @@ export const BonsaiForms = { TriggerOrdersFormFns, AdjustIsolatedMarginFormFns, TransferFormFns, + SpotFormFns, }; diff --git a/src/bonsai/rest/spot.ts b/src/bonsai/rest/spot.ts new file mode 100644 index 0000000000..fd3b3ab98b --- /dev/null +++ b/src/bonsai/rest/spot.ts @@ -0,0 +1,201 @@ +import { QueryObserver } from '@tanstack/react-query'; + +import { AppRoute } from '@/constants/routes'; +import { timeUnits } from '@/constants/time'; +import { SOL_MINT_ADDRESS } from '@/constants/tokens'; + +import { type RootStore } from '@/state/_store'; +import { getUserSolanaWalletAddress } from '@/state/accountInfoSelectors'; +import { appQueryClient } from '@/state/appQueryClient'; +import { getCurrentPath, getSpotApiEndpoint } from '@/state/appSelectors'; +import { createAppSelector } from '@/state/appTypes'; +import { + setSpotPortfolioTrades, + setSpotSolPrice, + setSpotTokenMetadata, + setSpotTokenPrice, +} from '@/state/raw'; +import { getCurrentSpotToken } from '@/state/spot'; + +import { + getSpotPortfolioTrades, + getSpotTokenMetadata, + getSpotTokenUsdPrice, +} from '@/clients/spotApi'; + +import { createStoreEffect } from '../lib/createStoreEffect'; +import { loadableIdle } from '../lib/loadable'; +import { logBonsaiError, wrapAndLogBonsaiError } from '../logs'; +import { queryResultToLoadable } from './lib/queryResultToLoadable'; +import { safeSubscribeObserver } from './lib/safeSubscribe'; + +const getSpotApiEndpointWhenOnSpotPage = createAppSelector( + [getCurrentPath, getSpotApiEndpoint, getCurrentSpotToken], + (currentPath, spotApiEndpoint, currentSpotToken) => { + if (!currentPath.startsWith(AppRoute.Spot) || !currentSpotToken) { + return null; + } + return { endpoint: spotApiEndpoint, currentSpotToken }; + } +); + +export function setUpTokenMetadataQuery(store: RootStore) { + return createStoreEffect(store, getSpotApiEndpointWhenOnSpotPage, (params) => { + if (!params) { + store.dispatch(setSpotTokenMetadata(loadableIdle())); + return () => {}; + } + + const { endpoint, currentSpotToken } = params; + + const observer = new QueryObserver(appQueryClient, { + queryKey: ['spot', 'tokenMetadata', currentSpotToken], + queryFn: wrapAndLogBonsaiError( + () => getSpotTokenMetadata(endpoint, currentSpotToken), + 'tokenMetadata' + ), + refetchInterval: timeUnits.minute * 5, + staleTime: timeUnits.minute * 5, + retryDelay: (attempt) => timeUnits.second * 3 * 2 ** attempt, + }); + + const unsubscribe = safeSubscribeObserver(observer, (result) => { + try { + store.dispatch(setSpotTokenMetadata(queryResultToLoadable(result))); + } catch (e) { + logBonsaiError( + 'setUpTokenMetadataQuery', + 'Error handling result from react query', + e, + result + ); + } + }); + + return () => { + store.dispatch(setSpotTokenMetadata(loadableIdle())); + unsubscribe(); + }; + }); +} + +export function setUpSolPriceQuery(store: RootStore) { + return createStoreEffect(store, getSpotApiEndpointWhenOnSpotPage, (params) => { + if (!params) { + store.dispatch(setSpotSolPrice(loadableIdle())); + return () => {}; + } + + const observer = new QueryObserver(appQueryClient, { + queryKey: ['spotTokenPrice', SOL_MINT_ADDRESS], + queryFn: wrapAndLogBonsaiError( + () => getSpotTokenUsdPrice(params.endpoint, SOL_MINT_ADDRESS), + 'solPrice' + ), + refetchInterval: timeUnits.second * 10, + staleTime: timeUnits.second * 10, + retry: true, + }); + + const unsubscribe = safeSubscribeObserver(observer, (result) => { + try { + store.dispatch(setSpotSolPrice(queryResultToLoadable(result))); + } catch (e) { + logBonsaiError('setUpSolPriceQuery', 'Error handling result from react query', e, result); + } + }); + + return () => { + store.dispatch(setSpotSolPrice(loadableIdle())); + unsubscribe(); + }; + }); +} + +export function setUpSpotTokenPriceQuery(store: RootStore) { + return createStoreEffect(store, getSpotApiEndpointWhenOnSpotPage, (params) => { + if (!params) { + store.dispatch(setSpotTokenPrice(loadableIdle())); + return () => {}; + } + + const observer = new QueryObserver(appQueryClient, { + queryKey: ['spotTokenPrice', params.currentSpotToken], + queryFn: wrapAndLogBonsaiError( + () => getSpotTokenUsdPrice(params.endpoint, params.currentSpotToken), + 'tokenPrice' + ), + refetchInterval: timeUnits.second * 10, + staleTime: timeUnits.second * 10, + retry: true, + }); + + const unsubscribe = safeSubscribeObserver(observer, (result) => { + try { + store.dispatch(setSpotTokenPrice(queryResultToLoadable(result))); + } catch (e) { + logBonsaiError( + 'setUpSpotTokenPriceQuery', + 'Error handling result from react query', + e, + result + ); + } + }); + + return () => { + store.dispatch(setSpotTokenPrice(loadableIdle())); + unsubscribe(); + }; + }); +} + +const getPortfolioTradesParams = createAppSelector( + [getCurrentPath, getSpotApiEndpoint, getUserSolanaWalletAddress], + (currentPath, spotApiEndpoint, walletAddress) => { + if (!currentPath.startsWith(AppRoute.Spot) || !walletAddress) { + return null; + } + return { endpoint: spotApiEndpoint, walletAddress }; + } +); + +export function setUpPortfolioTradesQuery(store: RootStore) { + return createStoreEffect(store, getPortfolioTradesParams, (params) => { + if (!params) { + store.dispatch(setSpotPortfolioTrades(loadableIdle())); + return () => {}; + } + + const { endpoint, walletAddress } = params; + + const observer = new QueryObserver(appQueryClient, { + queryKey: ['spot', 'portfolioTrades', walletAddress], + queryFn: wrapAndLogBonsaiError( + () => getSpotPortfolioTrades(endpoint, walletAddress), + 'portfolioTrades' + ), + refetchInterval: timeUnits.minute * 2, + staleTime: timeUnits.minute * 2, + retryDelay: (attempt) => timeUnits.second * 3 * 2 ** attempt, + }); + + const unsubscribe = safeSubscribeObserver(observer, (result) => { + try { + store.dispatch(setSpotPortfolioTrades(queryResultToLoadable(result))); + } catch (e) { + logBonsaiError( + 'setUpPortfolioTradesQuery', + 'Error handling result from react query', + e, + result + ); + } + }); + + return () => { + store.dispatch(setSpotPortfolioTrades(loadableIdle())); + unsubscribe(); + }; + }); +} diff --git a/src/bonsai/selectors/base.ts b/src/bonsai/selectors/base.ts index 8742b9d047..a744adf3c8 100644 --- a/src/bonsai/selectors/base.ts +++ b/src/bonsai/selectors/base.ts @@ -80,3 +80,22 @@ export const selectRawSelectedMarketLeverages = (state: RootState) => state.raw.markets.selectedMarketLeverages; export const selectRawSelectedMarketLeveragesData = (state: RootState) => state.raw.markets.selectedMarketLeverages.data; +export const selectRawSpotSolPrice = (state: RootState) => state.raw.spot.solPrice.data; +export const selectRawSpotSolPriceLoading = (state: RootState) => state.raw.spot.solPrice.status; +export const selectRawSpotTokenPrice = (state: RootState) => state.raw.spot.tokenPrice.data; +export const selectRawSpotTokenPriceLoading = (state: RootState) => + state.raw.spot.tokenPrice.status; + +export const selectRawSpotTokenMetadata = (state: RootState) => state.raw.spot.tokenMetadata.data; +export const selectRawSpotTokenMetadataLoading = (state: RootState) => + state.raw.spot.tokenMetadata.status; + +export const selectRawSpotWalletPositions = (state: RootState) => + state.raw.spot.walletPositions.data; +export const selectRawSpotWalletPositionsLoading = (state: RootState) => + state.raw.spot.walletPositions.status; + +export const selectRawSpotPortfolioTrades = (state: RootState) => + state.raw.spot.portfolioTrades.data; +export const selectRawSpotPortfolioTradesLoading = (state: RootState) => + state.raw.spot.portfolioTrades.status; diff --git a/src/bonsai/selectors/spot.ts b/src/bonsai/selectors/spot.ts new file mode 100644 index 0000000000..f5751c33ce --- /dev/null +++ b/src/bonsai/selectors/spot.ts @@ -0,0 +1,74 @@ +import { createAppSelector } from '@/state/appTypes'; + +import { + selectRawSpotPortfolioTrades, + selectRawSpotPortfolioTradesLoading, + selectRawSpotSolPrice, + selectRawSpotSolPriceLoading, + selectRawSpotTokenMetadata, + selectRawSpotTokenMetadataLoading, + selectRawSpotTokenPrice, + selectRawSpotTokenPriceLoading, + selectRawSpotWalletPositions, + selectRawSpotWalletPositionsLoading, +} from './base'; + +export const selectSpotTokenPrice = createAppSelector( + [selectRawSpotTokenPrice], + (tokenPrice) => tokenPrice?.price +); + +export const selectSpotTokenPriceLoading = createAppSelector( + [selectRawSpotTokenPriceLoading], + (loading) => loading +); + +export const selectSpotSolPrice = createAppSelector( + [selectRawSpotSolPrice], + (solPrice) => solPrice?.price +); + +export const selectSpotSolPriceLoading = createAppSelector( + [selectRawSpotSolPriceLoading], + (loading) => loading +); + +export const selectSpotTokenMetadata = createAppSelector( + [selectRawSpotTokenMetadata], + (tokenMetadata) => tokenMetadata?.tokenInfo +); + +export const selectSpotTokenMetadataLoading = createAppSelector( + [selectRawSpotTokenMetadataLoading], + (loading) => loading +); + +export const selectSpotWalletPositions = createAppSelector( + [selectRawSpotWalletPositions], + (walletPositions) => walletPositions +); + +export const selectSpotWalletPositionsLoading = createAppSelector( + [selectRawSpotWalletPositionsLoading], + (loading) => loading +); + +export const selectSpotPositions = createAppSelector( + [selectSpotWalletPositions], + (walletPositions) => walletPositions?.positions ?? [] +); + +export const selectSpotBalances = createAppSelector( + [selectSpotWalletPositions], + (walletPositions) => walletPositions?.tokenBalances ?? [] +); + +export const selectSpotPortfolioTrades = createAppSelector( + [selectRawSpotPortfolioTrades], + (portfolioTrades) => portfolioTrades ?? { trades: [], tokenData: {} } +); + +export const selectSpotPortfolioTradesLoading = createAppSelector( + [selectRawSpotPortfolioTradesLoading], + (loading) => loading +); diff --git a/src/bonsai/storeLifecycles.ts b/src/bonsai/storeLifecycles.ts index d731957cde..2238516869 100644 --- a/src/bonsai/storeLifecycles.ts +++ b/src/bonsai/storeLifecycles.ts @@ -19,6 +19,12 @@ import { setUpNobleBalanceQuery } from './rest/nobleBalance'; import { setUpOrdersQuery } from './rest/orders'; import { setUpRewardsParamsQuery, setUpRewardsTokenPriceQuery } from './rest/rewards'; import { setUpSparklinesQuery } from './rest/sparklines'; +import { + setUpPortfolioTradesQuery, + setUpSolPriceQuery, + setUpSpotTokenPriceQuery, + setUpTokenMetadataQuery, +} from './rest/spot'; import { setUpTransfersQuery } from './rest/transfers'; import { setUpAccountBalancesQuery, @@ -31,6 +37,15 @@ import { setUpMarketsFeeDiscountQuery } from './rest/validatorMarketsMetadata'; import { setUpMarkets } from './websocket/markets'; import { setUpOrderbook } from './websocket/orderbook'; import { setUpParentSubaccount } from './websocket/parentSubaccount'; +import { setUpSpotWalletPositions } from './websocket/spot'; + +const spotLifeCycles = [ + setUpSolPriceQuery, + setUpSpotTokenPriceQuery, + setUpSpotWalletPositions, + setUpTokenMetadataQuery, + setUpPortfolioTradesQuery, +]; export const storeLifecycles = [ alwaysUseCurrentNetworkClient, @@ -64,4 +79,5 @@ export const storeLifecycles = [ setUpReclaimChildSubaccountBalancesLifecycle, setUpMarketsFeeDiscountQuery, setUpAccountStakingTierQuery, + ...spotLifeCycles, ] as const; diff --git a/src/bonsai/websocket/spot.ts b/src/bonsai/websocket/spot.ts new file mode 100644 index 0000000000..a1221b958b --- /dev/null +++ b/src/bonsai/websocket/spot.ts @@ -0,0 +1,41 @@ +import { createStoreEffect } from '@/bonsai/lib/createStoreEffect'; +import { loadableIdle, loadableLoaded } from '@/bonsai/lib/loadable'; + +import { type RootStore } from '@/state/_store'; +import { getUserSolanaWalletAddress } from '@/state/accountInfoSelectors'; +import { getSpotApiEndpoint } from '@/state/appSelectors'; +import { createAppSelector } from '@/state/appTypes'; +import { setSpotWalletPositions } from '@/state/raw'; + +import { subscribeToWalletPositions } from '@/lib/streaming/walletPositionsStreaming'; + +const getWalletPositionsSubscriptionParams = createAppSelector( + [getSpotApiEndpoint, getUserSolanaWalletAddress], + (spotApiEndpoint, solanaWalletAddress) => { + if (!spotApiEndpoint || !solanaWalletAddress) { + return null; + } + + return { endpoint: spotApiEndpoint, walletAddress: solanaWalletAddress }; + } +); + +export function setUpSpotWalletPositions(store: RootStore) { + return createStoreEffect(store, getWalletPositionsSubscriptionParams, (params) => { + if (!params) { + store.dispatch(setSpotWalletPositions(loadableIdle())); + return () => {}; + } + + const { endpoint, walletAddress } = params; + + const unsubscribe = subscribeToWalletPositions(endpoint, walletAddress, (data) => { + store.dispatch(setSpotWalletPositions(loadableLoaded(data))); + }); + + return () => { + unsubscribe(); + store.dispatch(setSpotWalletPositions(loadableIdle())); + }; + }); +} diff --git a/src/clients/spotApi.ts b/src/clients/spotApi.ts new file mode 100644 index 0000000000..10c88dd310 --- /dev/null +++ b/src/clients/spotApi.ts @@ -0,0 +1,397 @@ +import { logBonsaiError } from '@/bonsai/logs'; + +import { DEFAULT_PRIORITY_FEE_LAMPORTS, DEFAULT_SLIPPAGE_BPS } from '@/constants/spot'; + +import { simpleFetch } from '@/lib/simpleFetch'; + +export enum SpotApiLandingMethod { + JITO = 'jito', + NORMAL = 'normal', + HELIUS = 'helius', + ZERO_SLOT = '0slot', + JUPITER = 'jupiter', +} + +export enum SpotApiSide { + BUY = 'buy', + SELL = 'sell', +} + +export enum SpotApiTradeRoute { + RAYDIUM = 'raydium', + METEORA = 'meteora', + PUMP_SWAP = 'pump-swap', + ORCA = 'orca', + PUMP = 'pump', + BONK = 'bonk', + LAUNCHLAB = 'launchlab', + BELIEVE = 'believe', + JUPITER = 'jupiter', +} + +export type SpotApiCreateTransactionRequest = { + account: string; + tokenMint: string; + side: SpotApiSide; + inAmount: string; + maxSlippageBps?: number; + pool: string; + tradeRoute: SpotApiTradeRoute; + priorityFeeLamports?: number; +}; + +export type SpotApiTransactionMetadataObject = { + timestamp: string; + pool: string; + route: string; + requestedRoute?: SpotApiTradeRoute; + side: SpotApiSide; + interfaceId?: string; + implementation?: string; + subRoute?: string; + fallbackUsed?: boolean; + latency?: number; + jupiterRequestId?: string; +}; + +export type SpotApiCreateTransactionResponse = { + transaction: string; + metadata: SpotApiTransactionMetadataObject; +}; + +export type SpotApiLandTransactionRequest = { + signedTransaction: string; + expectedTokenMint: string; + walletAddress?: string; + landingMethod?: SpotApiLandingMethod; + jupiterRequestId?: string; +}; + +export type SpotApiLandTransactionResponse = { + txHash: string; + landedAt: string; + confirmationMethod: string; + landingMethod: string; + side: SpotApiSide; + tokenMint: string; + walletAddress: string; + solChange: number; + tokenChange: number; + metrics: { + boughtUsd: number; + boughtAmount: number; + soldUsd: number; + soldAmount: number; + pnlUsd: number; + pnlPercent: number; + }; +}; + +export type SpotApiTokenPriceResponse = { + price: number; +}; + +export interface SpotApiTokenSocialLinksObject { + bitcointalk: string | null; + blog: string | null; + coingecko: string | null; + coinmarketcap: string | null; + discord: string | null; + email: string | null; + facebook: string | null; + github: string | null; + instagram: string | null; + linkedin: string | null; + reddit: string | null; + slack: string | null; + telegram: string | null; + twitch: string | null; + twitter: string | null; + website: string | null; + wechat: string | null; + whitepaper: string | null; + youtube: string | null; +} + +export interface SpotApiTokenInfoObject { + tokenMint?: string; + pairAddress?: string; + tradeRoute?: string; + symbol?: string; + tokenNameFull?: string; + image?: string; + tokenTimestamp?: string; + createdAt?: number; + socialLinks?: SpotApiTokenSocialLinksObject; + otherLinks?: string; + decimals?: number; + circulatingSupply?: string; + totalSupply?: string; + priceUSD?: number; + priceSOL?: number; + marketCapUSD?: number; + marketCapSOL?: number; + pricePercentChange1h?: number; + token1hPriceChange?: string; + pricePercentChange24h?: number; + token24hPriceChange?: string; + volumeUSD?: number; + tokenVolume?: string; + liquidityUSD?: number; + liquiditySOL?: number; + tokenLiquidity?: string; + token24hBuys?: number; + token24hSells?: number; + isPump?: boolean; + bondingCurveProgress?: number; + tokenFDV?: string; + isGraduating?: boolean; + tokenDexUrl?: string; + top10HoldersPercent?: number; + holders?: number; + devHoldingPercent?: number; + snipersPercent?: number; + bundlersPercent?: number; + insidersPercent?: number; +} + +export type SpotApiTokenMetadataResponse = { + tokenInfo: SpotApiTokenInfoObject; +}; + +export interface SpotApiSearchTokenInfoObject { + mint: string; + symbol: string; + name: string; + volumeUsd: number; + priceUsd: number; + priceChangePercent24h: number; + priceChangeAmount24h: number; + marketCapUSD: number; + imageSm: string | null; + imageLg: string | null; +} + +export type SpotApiTokenSearchResponse = { + tokens: SpotApiSearchTokenInfoObject[]; +}; + +export type SpotApiBarsResolution = + | '1S' + | '5S' + | '15S' + | '30S' + | '1' + | '5' + | '15' + | '30' + | '60' + | '240' + | '720' + | '1D' + | '7D'; + +export type SpotApiGetBarsQuery = { + from: number; // unix timestamp + tokenMint: string; + to?: number; // unix timestamp, defaults to current time + resolution?: SpotApiBarsResolution; +}; + +export interface SpotApiGetBarsResponseData { + buyVolume: string[]; + buyers: number[]; + buys: number[]; + c: number[]; + h: number[]; + l: number[]; + liquidity: string[]; + o: number[]; + sellVolume: string[]; + sellers: number[]; + sells: number[]; + t: number[]; + traders: number[]; + transactions: number[]; + volume: string[]; + volumeNativeToken: string[]; + s: string; + pair: Record; +} + +export type SpotApiGetBarsResponse = { + data: SpotApiGetBarsResponseData; +}; + +export interface SpotApiBarObject { + t: number; // timestamp (unix seconds) + o: number; // open + h: number; // high + l: number; // low + c: number; // close + volume: string; // volume in USD +} + +export interface SpotApiTradeObject { + id: string; + walletAddress: string; + tokenMint: string; + decimals: number; + side: SpotApiSide; + tokenAmount: number; + solAmount: number; + usdValue: number; + tokenPriceUsd: number; + txHash: string; + createdAt: string; +} + +export type SpotApiPortfolioTradesResponse = { + trades: SpotApiTradeObject[]; + tokenData: Record; +}; + +export class SpotApiClient { + private host: string; + + constructor(host: string) { + if (!host) { + logBonsaiError('SpotApiClient', 'SpotApiClient requires a host'); + } + + this.host = host; + } + + _get(endpoint: string) { + return simpleFetch(`${this.host}/${endpoint}`); + } + + _post(endpoint: string, body: unknown) { + return simpleFetch(`${this.host}/${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + } + + async getTokenPrice(mint: string) { + return this._get(`tokens/price?mint=${mint}`); + } + + async getTokenMetadata(mint: string) { + return this._get(`tokens/info?mint=${mint}`); + } + + async searchTokens(query?: string, limit = 200) { + return this._get( + `tokens/search?phrase=${encodeURIComponent(query ?? '')}&limit=${limit}` + ); + } + + async getBars(query: SpotApiGetBarsQuery) { + const params = new URLSearchParams({ + from: query.from.toString(), + tokenMint: query.tokenMint, + }); + + if (query.to) { + params.append('to', query.to.toString()); + } + + if (query.resolution) { + params.append('resolution', query.resolution); + } + + return this._get(`tokens/bars?${params.toString()}`); + } + + async createTransaction(request: SpotApiCreateTransactionRequest) { + return this._post('transactions/create', request); + } + + async landTransaction(request: SpotApiLandTransactionRequest) { + return this._post('transactions/land', request); + } + + async getPortfolioTrades(address: string) { + return this._get(`portfolio/${address}/trades`); + } + + get url() { + return this.host; + } +} + +let spotApiClient: SpotApiClient | null = null; + +const getOrCreateSpotApiClient = (apiUrl: string): SpotApiClient => { + if (!spotApiClient || spotApiClient.url !== apiUrl) { + spotApiClient = new SpotApiClient(apiUrl); + } + return spotApiClient; +}; + +export const getSpotTokenUsdPrice = async (apiUrl: string, mint: string) => { + const client = getOrCreateSpotApiClient(apiUrl); + return client.getTokenPrice(mint); +}; + +export const getSpotTokenMetadata = async (apiUrl: string, mint: string) => { + const client = getOrCreateSpotApiClient(apiUrl); + return client.getTokenMetadata(mint); +}; + +export const searchSpotTokens = async (apiUrl: string, query?: string) => { + const client = getOrCreateSpotApiClient(apiUrl); + return client.searchTokens(query); +}; + +export const createSpotTransaction = async ( + apiUrl: string, + request: SpotApiCreateTransactionRequest +) => { + const client = getOrCreateSpotApiClient(apiUrl); + + const requestWithDefaults: Required = { + ...request, + maxSlippageBps: request.maxSlippageBps ?? DEFAULT_SLIPPAGE_BPS, + priorityFeeLamports: request.priorityFeeLamports ?? DEFAULT_PRIORITY_FEE_LAMPORTS, + }; + + return client.createTransaction(requestWithDefaults); +}; + +export const landSpotTransaction = async ( + apiUrl: string, + request: SpotApiLandTransactionRequest +) => { + const client = getOrCreateSpotApiClient(apiUrl); + return client.landTransaction(request); +}; + +export const transformBarsResponseToBars = ( + response: SpotApiGetBarsResponse +): SpotApiBarObject[] => { + const { data } = response; + return data.t.map((t, i) => ({ + t, + o: data.o[i] ?? 0, + h: data.h[i] ?? 0, + l: data.l[i] ?? 0, + c: data.c[i] ?? 0, + volume: data.volume[i] ?? '0', + })); +}; + +export const getSpotBars = async (apiUrl: string, query: SpotApiGetBarsQuery) => { + const client = getOrCreateSpotApiClient(apiUrl); + const response = await client.getBars(query); + return transformBarsResponseToBars(response); +}; + +export const getSpotPortfolioTrades = async (apiUrl: string, address: string) => { + const client = getOrCreateSpotApiClient(apiUrl); + return client.getPortfolioTrades(address); +}; diff --git a/src/components/AssetIcon.tsx b/src/components/AssetIcon.tsx index a6fd04864f..ea20cc1916 100644 --- a/src/components/AssetIcon.tsx +++ b/src/components/AssetIcon.tsx @@ -1,12 +1,16 @@ -import { useState } from 'react'; +import { useContext, useState } from 'react'; import styled, { css } from 'styled-components'; import { ASSET_ICON_MAP } from '@/constants/assets'; import { CHAIN_INFO } from '@/constants/chains'; +import { LoadingContext } from '@/contexts/LoadingContext'; + import { Nullable } from '@/lib/typeUtils'; +import { LoadingAssetIcon } from './Loading/LoadingAssetIcon'; + export type AssetSymbol = keyof typeof ASSET_ICON_MAP; const Placeholder = ({ className, symbol }: { className?: string; symbol: string }) => ( @@ -23,13 +27,20 @@ export const AssetIcon = ({ symbol, className, chainId, + isLoading = false, }: { logoUrl?: Nullable; symbol?: Nullable; className?: string; chainId?: string; + isLoading?: boolean; }) => { const [isError, setIsError] = useState(false); + const isAssetIconLoading = useContext(LoadingContext); + + if (isLoading || isAssetIconLoading) { + return <$LoadingAssetIcon className={className} />; + } if (isError || (!logoUrl && !isAssetSymbol(symbol))) { return ; @@ -133,3 +144,7 @@ const $ChainIcon = styled.img` border-radius: 9px; border: 2px solid var(--asset-icon-chain-icon-borderColor, var(--color-layer-4)); `; + +const $LoadingAssetIcon = styled(LoadingAssetIcon)` + --loading-asset-icon-size: var(--asset-icon-size, 1em); +`; diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index 78e47fa799..0403fad8bf 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -16,6 +16,7 @@ export type CopyButtonProps = { buttonType?: 'text' | 'icon' | 'default'; children?: React.ReactNode; onCopy?: () => void; + copyIconPosition?: 'start' | 'end'; } & ButtonProps; export const CopyButton = ({ @@ -23,14 +24,20 @@ export const CopyButton = ({ buttonType = 'default', children, onCopy, + copyIconPosition = buttonType === 'default' ? 'start' : 'end', ...buttonProps }: CopyButtonProps) => { const { copied, copy, tooltipString } = useCopyValue({ value, onCopy }); return buttonType === 'text' ? ( <$InlineRow onClick={copy} copied={copied}> + {copyIconPosition === 'start' && ( + <$Icon $copied={copied} iconName={copied ? IconName.Check : IconName.Copy} /> + )} {children} - <$Icon $copied={copied} iconName={copied ? IconName.Check : IconName.Copy} /> + {copyIconPosition === 'end' && ( + <$Icon $copied={copied} iconName={copied ? IconName.Check : IconName.Copy} /> + )} ) : buttonType === 'icon' ? ( @@ -45,14 +52,16 @@ export const CopyButton = ({ ) : ( ); }; + const $InlineRow = styled.div<{ copied: boolean }>` ${layoutMixins.inlineRow} cursor: pointer; diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 22bd77c37b..2d4efbfa6d 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -19,6 +19,7 @@ import { ChatIcon, CheckCircleIcon, CheckIcon, + ChefHatIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, @@ -33,6 +34,7 @@ import { DepthChartIcon, DevicesStrokeIcon, DiscordIcon, + DollarSignIcon, DoubleChevronRightIcon, DownloadIcon, EarthIcon, @@ -51,6 +53,7 @@ import { FunkitStandardIcon, GearIcon, GearStrokeIcon, + GhostIcon, GiftboxIcon, GoogleIcon, GooglePlayIcon, @@ -85,7 +88,9 @@ import { PasskeyIcon, Pencil2Icon, PencilIcon, + PercentSignIcon, PlayIcon, + PlusCircleIcon, PlusIcon, PositionIcon, PositionPartialIcon, @@ -100,6 +105,7 @@ import { ResendIcon, RocketIcon, RoundedArrowIcon, + ScopeIcon, SearchIcon, SendIcon, SettingsIcon, @@ -109,6 +115,8 @@ import { ShowIcon, SocialLoginIcon, SocialXIcon, + SolIcon, + SolanaSimpleIcon, SparklesIcon, SpeechBubbleIcon, StarIcon, @@ -128,11 +136,15 @@ import { TriangleIcon, TrophyIcon, TryAgainIcon, + User2Icon, + UserGroupIcon, UserIcon, + UserShieldIcon, UsersIcon, ViewfinderIcon, VolumeIcon, Wallet2Icon, + Wallet3Icon, WalletIcon, WarningIcon, WebsiteIcon, @@ -148,6 +160,10 @@ import UsdcIcon from '@/icons/usdc.svg'; import { calc } from '@/lib/do'; export enum IconName { + Wallet3 = 'Wallet3', + PlusCircle = 'PlusCircle', + UserGroup = 'UserGroup', + DollarSign = 'DollarSign', AddressConnector = 'AddressConnector', Apple = 'Apple', AppleLight = 'AppleLight', @@ -163,6 +179,7 @@ export enum IconName { CautionCircleStroked = 'CautionCircleStroked', ChaosLabs = 'ChaosLabs', Chat = 'Chat', + ChefHat = 'ChefHat', Check = 'Check', CheckCircle = 'CheckCircle', ChevronLeft = 'ChevronLeft', @@ -197,6 +214,8 @@ export enum IconName { FunkitStandard = 'FunkitStandard', Gear = 'Gear', GearStroke = 'GearStroke', + Ghost = 'Ghost', + PercentSign = 'PercentSignIcon', Giftbox = 'Giftbox', Google = 'Google', GooglePlay = 'GooglePlay', @@ -251,6 +270,7 @@ export enum IconName { RewardStars = 'RewardStars', Rocket = 'Rocket', RoundedArrow = 'RoundedArrow', + Scope = 'Scope', Search = 'Search', Send = 'Send', Settings = 'Settings', @@ -292,9 +312,17 @@ export enum IconName { Withdraw = 'Withdraw', XCircle = 'XCircle', SocialX = 'SocialX', + Sol = 'Sol', + SolanaSimple = 'SolanaSimple', + User2 = 'User2', + UserShield = 'UserShield', } const icons = { + [IconName.PlusCircle]: PlusCircleIcon, + [IconName.Wallet3]: Wallet3Icon, + [IconName.UserGroup]: UserGroupIcon, + [IconName.DollarSign]: DollarSignIcon, [IconName.AddressConnector]: AddressConnectorIcon, [IconName.Apple]: AppleIcon, [IconName.AppleLight]: AppleLightIcon, @@ -310,6 +338,7 @@ const icons = { [IconName.CautionCircleStroked]: CautionCircleStrokeIcon, [IconName.ChaosLabs]: ChaosLabsIcon, [IconName.Chat]: ChatIcon, + [IconName.ChefHat]: ChefHatIcon, [IconName.Check]: CheckIcon, [IconName.CheckCircle]: CheckCircleIcon, [IconName.ChevronLeft]: ChevronLeftIcon, @@ -344,6 +373,7 @@ const icons = { [IconName.FunkitStandard]: FunkitStandardIcon, [IconName.Gear]: GearIcon, [IconName.GearStroke]: GearStrokeIcon, + [IconName.Ghost]: GhostIcon, [IconName.Giftbox]: GiftboxIcon, [IconName.Google]: GoogleIcon, [IconName.GooglePlay]: GooglePlayIcon, @@ -395,6 +425,8 @@ const icons = { [IconName.RewardStar]: undefined, [IconName.Rocket]: RocketIcon, [IconName.RoundedArrow]: RoundedArrowIcon, + [IconName.Scope]: ScopeIcon, + [IconName.PercentSign]: PercentSignIcon, [IconName.Search]: SearchIcon, [IconName.Send]: SendIcon, [IconName.Settings]: SettingsIcon, @@ -436,6 +468,10 @@ const icons = { [IconName.Withdraw]: WithdrawIcon, [IconName.XCircle]: XCircleIcon, [IconName.SocialX]: SocialXIcon, + [IconName.Sol]: SolIcon, + [IconName.SolanaSimple]: SolanaSimpleIcon, + [IconName.User2]: User2Icon, + [IconName.UserShield]: UserShieldIcon, } as Record; // we load reward-start async because it's gigantic for some reason diff --git a/src/components/InfoGrid.tsx b/src/components/InfoGrid.tsx new file mode 100644 index 0000000000..0e79fbfd3c --- /dev/null +++ b/src/components/InfoGrid.tsx @@ -0,0 +1,78 @@ +import { ReactNode } from 'react'; + +import styled from 'styled-components'; + +import { LoadingContext } from '@/contexts/LoadingContext'; + +export type InfoGridItemProps = { + // eslint-disable-next-line react/no-unused-prop-types + key: string; + label: ReactNode; + value?: ReactNode | null; +}; + +const InfoGridItem = ({ label, value }: InfoGridItemProps) => ( + <$Card> + <$Value>{value} + <$Label>{label} + +); + +type ElementProps = { + items: InfoGridItemProps[]; + isLoading?: boolean; +}; + +type StyleProps = { + className?: string; +}; + +export const InfoGrid = ({ className, items, isLoading = false }: ElementProps & StyleProps) => ( + + <$InfoGrid className={className}> + {items.map(({ key, label, value }) => ( + + ))} + + +); + +const $InfoGrid = styled.dl` + --infogrid-numColumns: 3; + + grid-auto-rows: 1fr; + gap: 0.5rem; + display: grid; + grid-template-columns: repeat(var(--infogrid-numColumns), minmax(0, 1fr)); +`; + +const $Card = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 0.85rem; + border: solid var(--border-width) var(--color-border); + border-radius: 0.5rem; +`; + +const $Value = styled.dd` + display: flex; + font: var(--font-base-book); + color: var(--color-text-2); + + &:empty { + color: var(--color-text-0); + opacity: 0.5; + + &:after { + content: '—'; + } + } +`; + +const $Label = styled.dt` + font: var(--font-mini-book); + color: var(--color-text-0); +`; diff --git a/src/components/Loading/Loading.stories.tsx b/src/components/Loading/Loading.stories.tsx index 6d4f01b5b3..8257a76bba 100644 --- a/src/components/Loading/Loading.stories.tsx +++ b/src/components/Loading/Loading.stories.tsx @@ -4,6 +4,7 @@ import { LoadingDots, LoadingDotsProps } from '@/components/Loading/LoadingDots' import { LoadingOutput } from '@/components/Loading/LoadingOutput'; import { LoadingSpinner } from '@/components/Loading/LoadingSpinner'; +import { LoadingAssetIcon } from './LoadingAssetIcon'; import { StoryWrapper } from '.ladle/components'; export const Dots: Story = (args) => { @@ -37,3 +38,13 @@ export const Output: Story = (args) => { }; Output.args = {}; + +export const AssetIcon: Story = (args) => { + return ( + + + + ); +}; + +AssetIcon.args = {}; diff --git a/src/components/Loading/LoadingAssetIcon.tsx b/src/components/Loading/LoadingAssetIcon.tsx new file mode 100644 index 0000000000..0efa9da2b8 --- /dev/null +++ b/src/components/Loading/LoadingAssetIcon.tsx @@ -0,0 +1,27 @@ +import styled, { keyframes } from 'styled-components'; + +export const LoadingAssetIcon = styled.div` + height: var(--loading-asset-icon-size, 1em); + min-height: var(--loading-asset-icon-size, 1em); + width: var(--loading-asset-icon-size, 1em); + min-width: var(--loading-asset-icon-size, 1em); + + background: linear-gradient( + 116deg, + hsla(245, 11%, 55%, 0.4) 0%, + currentColor 50%, + hsla(245, 11%, 55%, 0.4) 100% + ); + background-size: 200% auto; + border-radius: 50%; + opacity: 0.6; + + animation: ${keyframes` + from { + background-position: 0 0; + } + to { + background-position: -200% 0; + } + `} 1.5s linear infinite; +`; diff --git a/src/components/Output.tsx b/src/components/Output.tsx index 749226a6f6..8860d96694 100644 --- a/src/components/Output.tsx +++ b/src/components/Output.tsx @@ -8,6 +8,7 @@ import styled, { css } from 'styled-components'; import { SupportedLocales } from '@/constants/localization'; import { LEVERAGE_DECIMALS, + NumberSign, PERCENT_DECIMALS, SMALL_PERCENT_DECIMALS, SMALL_USD_DECIMALS, @@ -326,6 +327,7 @@ type StyleProps = { className?: string; withBaseFont?: boolean; withSignColor?: boolean; + withSignedValueColor?: boolean; }; export type OutputProps = ElementProps & StyleProps; @@ -349,6 +351,7 @@ export const Output = ({ withParentheses, showSign = ShowSign.Negative, withSignColor = false, + withSignedValueColor, dateOptions, relativeTimeOptions = { @@ -499,6 +502,15 @@ export const Output = ({ className={className} withParentheses={withParentheses} withBaseFont={withBaseFont} + valueColor={ + withSignedValueColor + ? isNegative + ? NumberSign.Negative + : isPositive + ? NumberSign.Positive + : undefined + : undefined + } > {slotLeft} {sign && ( @@ -553,10 +565,21 @@ const $Text = styled.output<{ withParentheses?: boolean }>` --output-afterString: ')'; `} `; -const $Number = styled($Text)<{ withBaseFont?: boolean }>` +const $Number = styled($Text)<{ withBaseFont?: boolean; valueColor?: NumberSign }>` ${({ withBaseFont }) => !withBaseFont && css` font-feature-settings: var(--fontFeature-monoNumbers); `} + + ${({ valueColor }) => + valueColor === NumberSign.Positive + ? css` + color: var(--color-positive) !important; + ` + : valueColor === NumberSign.Negative + ? css` + color: var(--color-negative) !important; + ` + : ''} `; diff --git a/src/components/TabGroup.tsx b/src/components/TabGroup.tsx index 49159562f2..56d77b3cd9 100644 --- a/src/components/TabGroup.tsx +++ b/src/components/TabGroup.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useRef } from 'react'; import styled from 'styled-components'; +import { layoutMixins } from '@/styles/layoutMixins'; + export interface TabOption { label: React.ReactNode; value: T; @@ -54,17 +56,21 @@ export const TabGroup = ({ value, onTabChange, options, className }: TabGrou return ( <$Container ref={containerRef} className={className}> <$ActiveTabIndicator ref={activeTabRef} /> - {options.map((option) => ( - <$Tab - disabled={option.disabled} - key={String(option.value)} - data-value={option.value} - onClick={() => onTabChange(option.value)} - $isActive={option.value === value} - > - {option.label} - - ))} + {options.map((option) => { + const isText = typeof option.label === 'string' || typeof option.label === 'number'; + + return ( + <$Tab + disabled={option.disabled} + key={String(option.value)} + data-value={option.value} + onClick={() => onTabChange(option.value)} + $isActive={option.value === value} + > + {isText ? <$TextItem>{option.label} : option.label} + + ); + })} ); }; @@ -98,7 +104,7 @@ const $Tab = styled.button<{ $isActive: boolean }>` background: transparent; border: none; border-radius: 0.5rem; - height: 3rem; + height: var(--tab-group-height, 3rem); color: ${({ $isActive }) => ($isActive ? 'var(--color-text-2)' : 'var(--color-text-0)')}; cursor: pointer; transition: color 0.2s ease-in-out; @@ -106,6 +112,9 @@ const $Tab = styled.button<{ $isActive: boolean }>` min-width: 0; font-size: 1rem; font-weight: var(--fontWeight-medium); + display: flex; + align-items: center; + justify-content: center; &:hover { color: var(--color-text-1); @@ -119,3 +128,7 @@ const $Tab = styled.button<{ $isActive: boolean }>` outline: 2px solid var(--color-accent); } `; + +const $TextItem = styled.span` + ${layoutMixins.textTruncate} +`; diff --git a/src/components/ValidationAlert.tsx b/src/components/ValidationAlert.tsx index d343b56ff1..af76d51272 100644 --- a/src/components/ValidationAlert.tsx +++ b/src/components/ValidationAlert.tsx @@ -37,14 +37,15 @@ export const ValidationAlertMessage = ({ > {stringGetter({ - key: - error.resources.text?.stringKey ?? - error.resources.title?.stringKey ?? - STRING_KEYS.UNKNOWN_ERROR, + key: error.resources.text?.stringKey ?? error.resources.title?.stringKey, params: renderParams({ params: error.resources.text?.params }) ?? renderParams({ params: error.resources.title?.params }) ?? {}, + fallback: + error.resources.text?.fallback ?? + error.resources.title?.fallback ?? + STRING_KEYS.UNKNOWN_ERROR, })} {ourLink != null ? ( <> diff --git a/src/constants/candles.ts b/src/constants/candles.ts index 7adf7ccfee..1d0b6a4ea9 100644 --- a/src/constants/candles.ts +++ b/src/constants/candles.ts @@ -1,6 +1,6 @@ import { ResolutionString } from 'public/tradingview/charting_library'; -import { SpotCandleServiceInterval } from '@/lib/tradingView/spotDatafeed/types'; +import { SpotApiBarsResolution } from '@/clients/spotApi'; import { MetadataServiceCandlesTimeframes } from './assetMetadata'; import { STRING_KEYS } from './localization'; @@ -122,6 +122,22 @@ export const RESOLUTION_CHART_CONFIGS = { '1D': { defaultRange: 2 * timeUnits.month }, } as Record; +export const SPOT_RESOLUTION_CHART_CONFIGS = { + '1S': { defaultRange: 5 * timeUnits.minute }, + '5S': { defaultRange: 10 * timeUnits.minute }, + '15S': { defaultRange: 30 * timeUnits.minute }, + '30S': { defaultRange: timeUnits.hour }, + '1': { defaultRange: 2 * timeUnits.hour }, + '5': { defaultRange: 10 * timeUnits.hour }, + '15': { defaultRange: 30 * timeUnits.hour }, + '30': { defaultRange: timeUnits.day }, + '60': { defaultRange: 6 * timeUnits.day }, + '240': { defaultRange: 24 * timeUnits.day }, + '720': { defaultRange: 48 * timeUnits.day }, + '1D': { defaultRange: 4 * timeUnits.month }, + '1W': { defaultRange: timeUnits.year }, +} as Record; + export const RESOLUTION_STRING_TO_LABEL = { '1': { value: '1', unitStringKey: STRING_KEYS.MINUTES_ABBREVIATED }, '5': { value: '5', unitStringKey: STRING_KEYS.MINUTES_ABBREVIATED }, @@ -133,7 +149,7 @@ export const RESOLUTION_STRING_TO_LABEL = { } as Record; /** - * @description ResolutionStrings used with TradingView's charting library mapped to SpotCandleServiceInterval + * @description ResolutionStrings used with TradingView's charting library mapped to SpotApiBarsResolution */ export const RESOLUTION_TO_SPOT_INTERVAL_MAP = { '1S': '1S', @@ -149,4 +165,4 @@ export const RESOLUTION_TO_SPOT_INTERVAL_MAP = { '720': '720', '1D': '1D', '1W': '7D', -} as Record; +} as Record; diff --git a/src/constants/spot.ts b/src/constants/spot.ts new file mode 100644 index 0000000000..6ab38db2e0 --- /dev/null +++ b/src/constants/spot.ts @@ -0,0 +1,8 @@ +export const DEFAULT_PRIORITY_FEE_LAMPORTS = 100000; +export const DEFAULT_SLIPPAGE_BPS = 2000; + +export const SPOT_DUST_USD_THRESHOLD = 0.01; +export const MIN_SOL_RESERVE = 0.002; +export const SOLANA_BASE_TRANSACTION_FEE = 0.000005; +export const SOL_WITHDRAWAL_POLL_INTERVAL_MS = 1000; +export const SOL_WITHDRAWAL_TIMEOUT_MS = 30000; diff --git a/src/constants/statsig.ts b/src/constants/statsig.ts index 36f90d3143..afeb6bd730 100644 --- a/src/constants/statsig.ts +++ b/src/constants/statsig.ts @@ -16,7 +16,7 @@ export enum StatsigFlags { ffSeptember2025Rewards = 'ff_rewards_sep_2025', ffTurnkeyWeb = 'ff_turnkey_web', ffSwapEnabled = 'ff_swap_ui_web', - + ffSpot = 'ff_spot', abPopupDeposit = 'ab_popup_deposit', } diff --git a/src/constants/styles/colors.ts b/src/constants/styles/colors.ts index b1e32e7822..a92fe6935c 100644 --- a/src/constants/styles/colors.ts +++ b/src/constants/styles/colors.ts @@ -32,6 +32,8 @@ type BaseColors = { green: string; red: string; + redFaded: string; + greenFaded: string; whiteFaded: string; }; diff --git a/src/constants/tokens.ts b/src/constants/tokens.ts index dae4675524..06fdd13f4c 100644 --- a/src/constants/tokens.ts +++ b/src/constants/tokens.ts @@ -27,6 +27,8 @@ export const USDC_DECIMALS = 6; export const DYDX_DECIMALS = 18; export const ETH_DECIMALS = 18; +export const SOL_MINT_ADDRESS = 'So11111111111111111111111111111111111111112'; + export type TokenForTransfer = { chainId: string; decimals: number; diff --git a/src/hooks/tradingView/useSpotChartMarketAndResolution.ts b/src/hooks/tradingView/useSpotChartMarketAndResolution.ts new file mode 100644 index 0000000000..2cab5d024f --- /dev/null +++ b/src/hooks/tradingView/useSpotChartMarketAndResolution.ts @@ -0,0 +1,70 @@ +import { useEffect, useRef } from 'react'; + +import type { ResolutionString } from 'public/tradingview/charting_library'; + +import { DEFAULT_RESOLUTION, SPOT_RESOLUTION_CHART_CONFIGS } from '@/constants/candles'; +import type { TvWidget } from '@/constants/tvchart'; + +import { useAppSelector } from '@/state/appTypes'; +import { getTvChartConfig } from '@/state/tradingViewSelectors'; + +import { getSavedResolution } from '@/lib/tradingView/utils'; + +/** + * @description Hook to handle changing spot tokens and setting chart resolution + */ + +export const useSpotChartMarketAndResolution = ({ + tokenMint, + tvWidget, +}: { + tokenMint: string; + tvWidget?: TvWidget; +}) => { + const savedTvChartConfig = useAppSelector((s) => getTvChartConfig(s, false, true)); + const savedResolution = (getSavedResolution({ savedConfig: savedTvChartConfig }) ?? + DEFAULT_RESOLUTION) as ResolutionString; + + const savedResolutionRef = useRef(savedResolution); + useEffect(() => { + savedResolutionRef.current = savedResolution; + }, [savedResolution]); + + useEffect(() => { + if (!tvWidget) return; + + tvWidget.onChartReady(() => { + const resolution = savedResolutionRef.current; + if (tokenMint !== tvWidget.activeChart().symbol()) { + tvWidget.setSymbol(tokenMint, resolution, () => {}); + } + + tvWidget + .activeChart() + .onIntervalChanged() + .subscribe(null, (newResolution) => { + setVisibleRangeForResolution(tvWidget, newResolution); + }); + + // Set visible range on initial render + setVisibleRangeForResolution(tvWidget, resolution); + }); + }, [tokenMint, tvWidget]); +}; + +const setVisibleRangeForResolution = (tvWidget: TvWidget, resolution: ResolutionString) => { + const defaultRange: number | undefined = SPOT_RESOLUTION_CHART_CONFIGS[resolution]?.defaultRange; + + if (defaultRange) { + const to = Date.now() / 1000; + const from = (Date.now() - defaultRange) / 1000; + + tvWidget.activeChart().setVisibleRange( + { + from, + to, + }, + { percentRightMargin: 10 } + ); + } +}; diff --git a/src/hooks/tradingView/useSpotTradingView.ts b/src/hooks/tradingView/useSpotTradingView.ts index ba0e59a05c..add84a20bb 100644 --- a/src/hooks/tradingView/useSpotTradingView.ts +++ b/src/hooks/tradingView/useSpotTradingView.ts @@ -14,7 +14,7 @@ import type { TvWidget } from '@/constants/tvchart'; import { useEndpointsConfig } from '@/hooks/useEndpointsConfig'; -import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { useAppDispatch, useAppSelector, useAppStore } from '@/state/appTypes'; import { getAppColorMode, getAppTheme } from '@/state/appUiConfigsSelectors'; import { getSelectedLocale } from '@/state/localizationSelectors'; import { updateSpotChartConfig } from '@/state/tradingView'; @@ -28,18 +28,19 @@ import { useSimpleUiEnabled } from '../useSimpleUiEnabled'; export const useSpotTradingView = ({ setTvWidget, - symbol, + tokenMint, }: { setTvWidget: Dispatch>; - symbol: string; + tokenMint: string; }) => { const dispatch = useAppDispatch(); + const store = useAppStore(); const { isTablet } = useBreakpoints(); const appTheme = useAppSelector(getAppTheme); const appColorMode = useAppSelector(getAppColorMode); const selectedLocale = useAppSelector(getSelectedLocale); const isSimpleUi = useSimpleUiEnabled(); - const { spotCandleService } = useEndpointsConfig(); + const { spotApi } = useEndpointsConfig(); const savedTvChartConfig = useAppSelector((state) => getTvChartConfig(state, false, true)); @@ -49,7 +50,7 @@ export const useSpotTradingView = ({ ); useEffect(() => { - if (!symbol || !spotCandleService) { + if (!tokenMint || !spotApi) { return () => {}; } @@ -60,10 +61,10 @@ export const useSpotTradingView = ({ const options: TradingTerminalWidgetOptions = { ...widgetOptions, ...widgetOverrides, - datafeed: getSpotDatafeed(spotCandleService), + datafeed: getSpotDatafeed(store, spotApi), interval: (savedResolution ?? DEFAULT_RESOLUTION) as ResolutionString, locale: languageCode as LanguageCode, - symbol, + symbol: tokenMint, saved_data: !isEmpty(savedTvChartConfig) ? savedTvChartConfig : undefined, auto_save_delay: 1, }; @@ -83,14 +84,5 @@ export const useSpotTradingView = ({ tvChartWidget.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - selectedLocale, - symbol, - spotCandleService, - setTvWidget, - isSimpleUi, - isTablet, - savedResolution, - dispatch, - ]); + }, [selectedLocale, !!tokenMint, spotApi, setTvWidget, isSimpleUi, isTablet, dispatch]); }; diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index 2337a01e88..96c17a6c3f 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -4,6 +4,7 @@ import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; import { BonsaiCore } from '@/bonsai/ontology'; import { type LocalWallet, NOBLE_BECH32_PREFIX, type Subaccount } from '@dydxprotocol/v4-client-js'; import { usePrivy } from '@privy-io/react-auth'; +import { Keypair } from '@solana/web3.js'; import { AES, enc } from 'crypto-js'; import { OnboardingGuard, OnboardingState } from '@/constants/account'; @@ -25,18 +26,17 @@ import { import { useTurnkeyWallet } from '@/providers/TurnkeyWalletProvider'; import { setOnboardingGuard, setOnboardingState } from '@/state/account'; -import { getGeo } from '@/state/accountSelectors'; import { getSelectedDydxChainId } from '@/state/appSelectors'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { clearSavedEncryptedSignature, setLocalWallet } from '@/state/wallet'; import { getSourceAccount } from '@/state/walletSelectors'; import { hdKeyManager, localWalletManager } from '@/lib/hdKeyManager'; +import { deriveSolanaKeypairFromMnemonic } from '@/lib/solanaWallet'; import { log } from '@/lib/telemetry'; import { sleep } from '@/lib/timeUtils'; import { useDydxClient } from './useDydxClient'; -import { useEnvFeatures } from './useEnvFeatures'; import { useLocalStorage } from './useLocalStorage'; import useSignForWalletDerivation from './useSignForWalletDerivation'; import { useWalletConnection } from './useWalletConnection'; @@ -53,8 +53,6 @@ export const useAccounts = () => useContext(AccountsContext)!; const useAccountsContext = () => { const dispatch = useAppDispatch(); - const geo = useAppSelector(getGeo); - const { checkForGeo } = useEnvFeatures(); const selectedDydxChainId = useAppSelector(getSelectedDydxChainId); const { endTurnkeySession } = useTurnkeyWallet(); @@ -75,10 +73,6 @@ const useAccountsContext = () => { const { ready, authenticated } = usePrivy(); - const blockedGeo = useMemo(() => { - return geo.currentlyGeoBlocked && checkForGeo; - }, [geo, checkForGeo]); - const [previousAddress, setPreviousAddress] = useState(sourceAccount.address); useEffect(() => { @@ -135,6 +129,7 @@ const useAccountsContext = () => { const [localNobleWallet, setLocalNobleWallet] = useState(); const [localOsmosisWallet, setLocalOsmosisWallet] = useState(); const [localNeutronWallet, setLocalNeutronWallet] = useState(); + const [localSolanaKeypair, setLocalSolanaKeypair] = useState(); const [hdKey, setHdKey] = useState(); @@ -145,9 +140,18 @@ const useAccountsContext = () => { [localDydxWallet] ); + const canDeriveSolanaWallet = useMemo(() => { + return sourceAccount.chain !== WalletNetworkType.Cosmos; + }, [sourceAccount.chain]); + + const solanaAddress = useMemo( + () => localSolanaKeypair?.publicKey.toBase58(), + [localSolanaKeypair] + ); + useEffect(() => { - dispatch(setLocalWallet({ address: dydxAddress, subaccountNumber: 0 })); - }, [dispatch, dydxAddress]); + dispatch(setLocalWallet({ address: dydxAddress, solanaAddress, subaccountNumber: 0 })); + }, [dispatch, dydxAddress, solanaAddress]); const nobleAddress = localNobleWallet?.address; const osmosisAddress = localOsmosisWallet?.address; @@ -172,12 +176,12 @@ const useAccountsContext = () => { const hasLocalDydxWallet = Boolean(localDydxWallet); useEffect(() => { - if (localDydxWallet && localNobleWallet) { - localWalletManager.setLocalWallet(localDydxWallet, localNobleWallet); + if (localDydxWallet && localNobleWallet && localSolanaKeypair) { + localWalletManager.setLocalWallet(localDydxWallet, localNobleWallet, localSolanaKeypair); } else { localWalletManager.clearLocalWallet(); } - }, [localDydxWallet, localNobleWallet]); + }, [localDydxWallet, localNobleWallet, localSolanaKeypair]); useEffect(() => { (async () => { @@ -186,7 +190,7 @@ const useAccountsContext = () => { * There will not be an OnboardingState.WalletConnected state, only AccountConnected or Disconnected. */ if (sourceAccount.walletInfo?.connectorType === ConnectorType.Turnkey) { - if (!hasLocalDydxWallet && sourceAccount.encryptedSignature && !blockedGeo) { + if (!hasLocalDydxWallet && sourceAccount.encryptedSignature) { try { const signature = decryptSignature(sourceAccount.encryptedSignature); await setWalletFromSignature(signature); @@ -244,7 +248,7 @@ const useAccountsContext = () => { log('useAccounts/decryptSignature', error); dispatch(clearSavedEncryptedSignature()); } - } else if (sourceAccount.encryptedSignature && !blockedGeo) { + } else if (sourceAccount.encryptedSignature) { try { const signature = decryptSignature(sourceAccount.encryptedSignature); @@ -262,7 +266,7 @@ const useAccountsContext = () => { if (!hasLocalDydxWallet) { dispatch(setOnboardingState(OnboardingState.WalletConnected)); - if (sourceAccount.encryptedSignature && !blockedGeo) { + if (sourceAccount.encryptedSignature) { try { const signature = decryptSignature(sourceAccount.encryptedSignature); await setWalletFromSignature(signature); @@ -280,13 +284,15 @@ const useAccountsContext = () => { dispatch(setOnboardingState(OnboardingState.Disconnected)); } })(); - }, [signerWagmi, isConnectedGraz, sourceAccount, hasLocalDydxWallet, blockedGeo]); + }, [signerWagmi, isConnectedGraz, sourceAccount, hasLocalDydxWallet]); useEffect(() => { const setCosmosWallets = async () => { let nobleWallet: LocalWallet | undefined; let osmosisWallet: LocalWallet | undefined; let neutronWallet: LocalWallet | undefined; + let solanaKeypair: Keypair | undefined; + if (hdKey?.mnemonic) { nobleWallet = await ( await getLazyLocalWallet() @@ -297,6 +303,7 @@ const useAccountsContext = () => { neutronWallet = await ( await getLazyLocalWallet() ).fromMnemonic(hdKey.mnemonic, NEUTRON_BECH32_PREFIX); + solanaKeypair = deriveSolanaKeypairFromMnemonic(hdKey.mnemonic); } try { @@ -326,6 +333,9 @@ const useAccountsContext = () => { if (neutronWallet !== undefined) { setLocalNeutronWallet(neutronWallet); } + if (solanaKeypair !== undefined) { + setLocalSolanaKeypair(solanaKeypair); + } } catch (error) { log('useAccounts/setCosmosWallets', error); } @@ -368,18 +378,13 @@ const useAccountsContext = () => { ); }, [dispatch, dydxSubaccounts]); - useEffect(() => { - if (blockedGeo) { - disconnect(); - } - }, [blockedGeo]); - // Disconnect wallet / accounts const disconnectLocalDydxWallet = () => { setLocalDydxWallet(undefined); setLocalNobleWallet(undefined); setLocalOsmosisWallet(undefined); setLocalNeutronWallet(undefined); + setLocalSolanaKeypair(undefined); setHdKey(undefined); hdKeyManager.clearHdkey(); }; @@ -421,6 +426,11 @@ const useAccountsContext = () => { osmosisAddress, neutronAddress, + // Solana spot accounts + solanaAddress, + localSolanaKeypair, + canDeriveSolanaWallet, + // Onboarding state saveHasAcknowledgedTerms, diff --git a/src/hooks/useComplianceState.tsx b/src/hooks/useComplianceState.tsx index 7ac5092937..bb66de6476 100644 --- a/src/hooks/useComplianceState.tsx +++ b/src/hooks/useComplianceState.tsx @@ -1,10 +1,12 @@ import { useMemo } from 'react'; import { ComplianceStatus } from '@/bonsai/types/summaryTypes'; +import { useMatch } from 'react-router-dom'; import { OnboardingState } from '@/constants/account'; import { CLOSE_ONLY_GRACE_PERIOD, ComplianceStates } from '@/constants/compliance'; import { STRING_KEYS } from '@/constants/localization'; +import { AppRoute } from '@/constants/routes'; import { Link } from '@/components/Link'; import { OutputType, formatDateOutput } from '@/components/Output'; @@ -19,6 +21,7 @@ import { import { useAppSelector } from '@/state/appTypes'; import { getSelectedLocale } from '@/state/localizationSelectors'; +import { useEnableSpot } from './useEnableSpot'; import { useEnvFeatures } from './useEnvFeatures'; import { useStringGetter } from './useStringGetter'; import { useURLConfigs } from './useURLConfigs'; @@ -32,6 +35,8 @@ export const useComplianceState = () => { const selectedLocale = useAppSelector(getSelectedLocale); const onboardingState = useAppSelector(getOnboardingState); const { checkForGeo } = useEnvFeatures(); + const isSpotPage = useMatch(`${AppRoute.Spot}/*`) != null; + const isSpotEnabled = useEnableSpot(); const complianceState = useMemo(() => { if ( @@ -98,15 +103,16 @@ export const useComplianceState = () => { const disableConnectButton = complianceState === ComplianceStates.READ_ONLY && - onboardingState === OnboardingState.Disconnected; + onboardingState === OnboardingState.Disconnected && + !isSpotEnabled; return { complianceStatus, complianceState, complianceMessage, disableConnectButton, - showRestrictionWarning: complianceState === ComplianceStates.READ_ONLY, + showRestrictionWarning: complianceState === ComplianceStates.READ_ONLY && !isSpotPage, showComplianceBanner: - complianceMessage != null || complianceState === ComplianceStates.READ_ONLY, + (complianceMessage != null || complianceState === ComplianceStates.READ_ONLY) && !isSpotPage, }; }; diff --git a/src/hooks/useCurrentSpotToken.ts b/src/hooks/useCurrentSpotToken.ts new file mode 100644 index 0000000000..d19935dc2b --- /dev/null +++ b/src/hooks/useCurrentSpotToken.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +import { useMatch } from 'react-router-dom'; + +import { AppRoute } from '@/constants/routes'; + +import { useAppDispatch } from '@/state/appTypes'; +import { setCurrentSpotToken } from '@/state/spot'; + +export const useCurrentSpotToken = () => { + const match = useMatch(`/${AppRoute.Spot}/:symbol`); + const { symbol } = match?.params ?? {}; + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(setCurrentSpotToken(symbol)); + }, [symbol, dispatch]); + + return { currentSpotToken: symbol }; +}; diff --git a/src/hooks/useCustomNotification.ts b/src/hooks/useCustomNotification.ts index 5f34d1fcc0..35af81fc50 100644 --- a/src/hooks/useCustomNotification.ts +++ b/src/hooks/useCustomNotification.ts @@ -6,7 +6,7 @@ import { useAppDispatch } from '@/state/appTypes'; import { addCustomNotification } from '@/state/notifications'; type CustomNoticationOptions = { - id: string; + id?: string; toastDuration?: number; }; diff --git a/src/hooks/useEnableSpot.ts b/src/hooks/useEnableSpot.ts new file mode 100644 index 0000000000..6998dd8ae5 --- /dev/null +++ b/src/hooks/useEnableSpot.ts @@ -0,0 +1,12 @@ +import { StatsigFlags } from '@/constants/statsig'; + +import { testFlags } from '@/lib/testFlags'; + +import { useStatsigGateValue } from './useStatsig'; + +export const useEnableSpot = () => { + const forcedSpot = testFlags.spot; + const spotFF = useStatsigGateValue(StatsigFlags.ffSpot); + + return forcedSpot || spotFF; +}; diff --git a/src/hooks/useEndpointsConfig.ts b/src/hooks/useEndpointsConfig.ts index 9c1d6f4090..2f724102f8 100644 --- a/src/hooks/useEndpointsConfig.ts +++ b/src/hooks/useEndpointsConfig.ts @@ -17,7 +17,7 @@ export interface EndpointsConfig { stakingAPR?: string; solanaRpcUrl: string; affiliates?: string; - spotCandleService?: string; + spotApi: string; geoV2: string; } @@ -36,7 +36,7 @@ export const useEndpointsConfig = () => { stakingAPR: endpointsConfig.stakingAPR, solanaRpcUrl: endpointsConfig.solanaRpcUrl, affiliatesBaseUrl: endpointsConfig.affiliates, - spotCandleService: endpointsConfig.spotCandleService, + spotApi: endpointsConfig.spotApi, geoV2: endpointsConfig.geoV2, }; }; diff --git a/src/hooks/useNotificationTypes.tsx b/src/hooks/useNotificationTypes.tsx index 2a785fca2f..370be2e844 100644 --- a/src/hooks/useNotificationTypes.tsx +++ b/src/hooks/useNotificationTypes.tsx @@ -66,6 +66,7 @@ import { import { getSelectedLocale } from '@/state/localizationSelectors'; import { getCustomNotifications } from '@/state/notificationsSelectors'; import { getSwaps } from '@/state/swapSelectors'; +import { isSpotWithdraw } from '@/state/transfers'; import { selectTransfersByAddress } from '@/state/transfersSelectors'; import { selectIsKeplrConnected } from '@/state/walletSelectors'; @@ -330,17 +331,19 @@ export const notificationTypes: NotificationTypeConfig[] = [ const { type, status } = transfer; const id = transfer.id; - const finalAmount = formatNumberOutput( - transfer.finalAmountUsd ?? transfer.estimatedAmountUsd, - OutputType.Fiat, - { decimalSeparator, groupSeparator, selectedLocale } - ); + const finalAmount = isSpotWithdraw(transfer) + ? `${formatNumberOutput(transfer.amount, OutputType.Number, { decimalSeparator, groupSeparator, selectedLocale, fractionDigits: 4 })} SOL` + : formatNumberOutput( + transfer.finalAmountUsd ?? transfer.estimatedAmountUsd, + OutputType.Fiat, + { decimalSeparator, groupSeparator, selectedLocale } + ); const isSuccess = status === 'success'; let body: string = ''; let title: string = ''; - if (type === 'withdraw') { + if (type === 'withdraw' || type === 'spot-withdraw') { title = stringGetter({ key: isSuccess ? STRING_KEYS.WITHDRAW : STRING_KEYS.WITHDRAW_IN_PROGRESS, }); diff --git a/src/hooks/useSolanaConnection.ts b/src/hooks/useSolanaConnection.ts index 6394b0254c..e022204da0 100644 --- a/src/hooks/useSolanaConnection.ts +++ b/src/hooks/useSolanaConnection.ts @@ -1,17 +1,12 @@ -import React from 'react'; - -import { Connection as SolanaConnection } from '@solana/web3.js'; - import { useEndpointsConfig } from '@/hooks/useEndpointsConfig'; -// Create and export a single instance of Connection -export const useSolanaConnection = () => { - const endpointsConfig = useEndpointsConfig(); +import { getSolanaConnection } from '@/lib/solanaConnection'; - const connection = React.useMemo( - () => new SolanaConnection(endpointsConfig.solanaRpcUrl), - [endpointsConfig.solanaRpcUrl] - ); - - return connection; +/** + * React hook that returns a singleton Solana connection instance. + * Automatically uses the RPC URL from endpoints config. + */ +export const useSolanaConnection = () => { + const { solanaRpcUrl } = useEndpointsConfig(); + return getSolanaConnection(solanaRpcUrl); }; diff --git a/src/hooks/useSpotForm.tsx b/src/hooks/useSpotForm.tsx new file mode 100644 index 0000000000..4efadd0217 --- /dev/null +++ b/src/hooks/useSpotForm.tsx @@ -0,0 +1,165 @@ +import { useCallback, useMemo } from 'react'; + +import { SpotBuyInputType, SpotSellInputType, SpotSide } from '@/bonsai/forms/spot'; +import { ErrorType, getHighestPriorityAlert } from '@/bonsai/lib/validationErrors'; +import { BonsaiCore } from '@/bonsai/ontology'; + +import { useAccounts } from '@/hooks/useAccounts'; +import { useCustomNotification } from '@/hooks/useCustomNotification'; +import { useLocaleSeparators } from '@/hooks/useLocaleSeparators'; +import { useSpotTransactionSubmit } from '@/hooks/useSpotTransactionSubmit'; + +import { Icon, IconName } from '@/components/Icon'; +import { formatNumberOutput, OutputType } from '@/components/Output'; + +import { appQueryClient } from '@/state/appQueryClient'; +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { getSelectedLocale } from '@/state/localizationSelectors'; +import { spotFormActions } from '@/state/spotForm'; +import { getSpotFormSummary } from '@/state/spotFormSelectors'; + +import { SpotApiSide } from '@/clients/spotApi'; + +// TODO: spot localization + +export function useSpotForm() { + const dispatch = useAppDispatch(); + const formSummary = useAppSelector(getSpotFormSummary); + const { canDeriveSolanaWallet } = useAccounts(); + const { mutateAsync: submitTransactionMutation, isPending } = useSpotTransactionSubmit(); + const notify = useCustomNotification(); + const tokenMetadata = useAppSelector(BonsaiCore.spot.tokenMetadata.data); + const { decimal: decimalSeparator, group: groupSeparator } = useLocaleSeparators(); + const selectedLocale = useAppSelector(getSelectedLocale); + + const hasErrors = useMemo( + () => formSummary.errors.some((error) => error.type === ErrorType.error), + [formSummary.errors] + ); + + const primaryAlert = useMemo( + () => getHighestPriorityAlert(formSummary.errors), + [formSummary.errors] + ); + + const canSubmit = useMemo( + () => canDeriveSolanaWallet && !hasErrors && formSummary.summary.payload != null && !isPending, + [canDeriveSolanaWallet, formSummary.summary.payload, hasErrors, isPending] + ); + + const actions = useMemo( + () => ({ + setSide: (side: SpotSide) => dispatch(spotFormActions.setSide(side)), + setBuyInputType: (type: Parameters[0]) => + dispatch(spotFormActions.setBuyInputType(type)), + setSellInputType: (type: Parameters[0]) => + dispatch(spotFormActions.setSellInputType(type)), + setSize: (size: string) => dispatch(spotFormActions.setSize(size)), + reset: () => dispatch(spotFormActions.reset()), + }), + [dispatch] + ); + + const handleInputTypeChange = useCallback( + (side: SpotSide, type: SpotBuyInputType | SpotSellInputType) => { + const amounts = formSummary.summary.amounts; + + if (side === SpotSide.BUY) { + const nextType = type as SpotBuyInputType; + const nextSizeNum = nextType === SpotBuyInputType.USD ? amounts?.usd : amounts?.sol; + + dispatch(spotFormActions.setBuyInputType(nextType)); + dispatch(spotFormActions.setSize(nextSizeNum?.toString() ?? '')); + return; + } + + const nextType = type as SpotSellInputType; + const nextSizeNum = nextType === SpotSellInputType.USD ? amounts?.usd : amounts?.percent; + + dispatch(spotFormActions.setSellInputType(nextType)); + dispatch(spotFormActions.setSize(nextSizeNum?.toString() ?? '')); + }, + [dispatch, formSummary.summary.amounts] + ); + + const submitTransaction = useCallback(async () => { + const payload = formSummary.summary.payload; + if (!payload) { + throw new Error('No payload available'); + } + + try { + const result = await submitTransactionMutation(payload); + dispatch(spotFormActions.reset()); + + appQueryClient.invalidateQueries({ + queryKey: ['spot', 'portfolioTrades'], + exact: false, + }); + + const { landResponse } = result; + const isBuy = landResponse.side === SpotApiSide.BUY; + const tokenSymbol = tokenMetadata?.symbol ?? ''; + + const formattedTokenAmount = formatNumberOutput(landResponse.tokenChange, OutputType.Asset, { + decimalSeparator, + groupSeparator, + selectedLocale, + }); + + const formattedSolAmount = formatNumberOutput(landResponse.solChange, OutputType.Asset, { + decimalSeparator, + groupSeparator, + selectedLocale, + }); + + notify( + { + title: 'Trade Successful', + slotTitleLeft: , + body: `${isBuy ? 'Purchased' : 'Sold'} ${formattedTokenAmount} ${tokenSymbol} for ${formattedSolAmount} SOL`, + }, + { + toastDuration: 5000, + } + ); + + return result; + } catch (error) { + notify( + { + title: 'Transaction Failed', + slotTitleLeft: , + body: 'Transaction failed. Please try again.', + }, + { + toastDuration: 5000, + } + ); + throw error; + } + }, [ + dispatch, + formSummary.summary.payload, + submitTransactionMutation, + tokenMetadata?.symbol, + decimalSeparator, + groupSeparator, + selectedLocale, + notify, + ]); + + return { + state: formSummary.state, + actions, + summary: formSummary.summary, + errors: formSummary.errors, + inputData: formSummary.inputData, + hasErrors, + primaryAlert, + canSubmit, + isPending, + handleInputTypeChange, + submitTransaction, + }; +} diff --git a/src/hooks/useSpotTokenSearch.ts b/src/hooks/useSpotTokenSearch.ts new file mode 100644 index 0000000000..7c5ec5a812 --- /dev/null +++ b/src/hooks/useSpotTokenSearch.ts @@ -0,0 +1,35 @@ +import { wrapAndLogBonsaiError } from '@/bonsai/logs'; +import { useQuery } from '@tanstack/react-query'; + +import { timeUnits } from '@/constants/time'; + +import { SpotHeaderToken } from '@/pages/spot/types'; + +import { searchSpotTokens } from '@/clients/spotApi'; + +import { useDebounce } from './useDebounce'; +import { useEndpointsConfig } from './useEndpointsConfig'; + +export const useSpotTokenSearch = (query: string, debounceMs = 300) => { + const spotApiEndpoint = useEndpointsConfig().spotApi; + const debouncedQuery = useDebounce(query, debounceMs); + + return useQuery({ + queryKey: ['spotTokenSearch', debouncedQuery], + queryFn: wrapAndLogBonsaiError(async (): Promise => { + const res = await searchSpotTokens(spotApiEndpoint, debouncedQuery); + return res.tokens.map((token) => ({ + name: token.name, + symbol: token.symbol, + tokenAddress: token.mint, + change24hPercent: token.priceChangePercent24h, + marketCapUsd: token.marketCapUSD, + markPriceUsd: token.priceUsd, + logoUrl: token.imageLg ?? token.imageSm, + priceUsd: token.priceUsd, + volume24hUsd: token.volumeUsd, + })); + }, 'spotTokenSearch'), + staleTime: timeUnits.minute, + }); +}; diff --git a/src/hooks/useSpotTransactionSubmit.ts b/src/hooks/useSpotTransactionSubmit.ts new file mode 100644 index 0000000000..2458aae0ee --- /dev/null +++ b/src/hooks/useSpotTransactionSubmit.ts @@ -0,0 +1,63 @@ +import { wrapAndLogBonsaiError } from '@/bonsai/logs'; +import { VersionedTransaction } from '@solana/web3.js'; +import { useMutation } from '@tanstack/react-query'; +import bs58 from 'bs58'; + +import { + SpotApiCreateTransactionRequest, + SpotApiLandingMethod, + createSpotTransaction, + landSpotTransaction, +} from '@/clients/spotApi'; + +import { useAccounts } from './useAccounts'; +import { useEndpointsConfig } from './useEndpointsConfig'; + +export const useSpotTransactionSubmit = () => { + const { localSolanaKeypair, solanaAddress, canDeriveSolanaWallet } = useAccounts(); + const spotApiEndpoint = useEndpointsConfig().spotApi; + + return useMutation({ + mutationFn: wrapAndLogBonsaiError(async (request: SpotApiCreateTransactionRequest) => { + if (!canDeriveSolanaWallet) { + throw new Error( + 'Spot trading is not available for Cosmos wallets. Please connect an Ethereum or Solana wallet.' + ); + } + + if (!localSolanaKeypair || !solanaAddress) { + throw new Error('Solana wallet not derived. Please reconnect your wallet.'); + } + + const requestWithAccount: SpotApiCreateTransactionRequest = { + ...request, + account: solanaAddress, + }; + + // Get unsigned transaction from backend + const createResponse = await createSpotTransaction(spotApiEndpoint, requestWithAccount); + + // Deserialize and sign with derived Solana keypair + const transaction = VersionedTransaction.deserialize( + Buffer.from(createResponse.transaction, 'base64') + ); + transaction.sign([localSolanaKeypair]); + + // Submit signed transaction (backend expects base58 encoding) + const signedTransactionBase58 = bs58.encode(transaction.serialize()); + const landResponse = await landSpotTransaction(spotApiEndpoint, { + signedTransaction: signedTransactionBase58, + expectedTokenMint: request.tokenMint, + landingMethod: createResponse.metadata.jupiterRequestId + ? SpotApiLandingMethod.JUPITER + : undefined, + jupiterRequestId: createResponse.metadata.jupiterRequestId, + }); + + return { + createResponse, + landResponse, + }; + }, 'spotTransactionSubmit'), + }); +}; diff --git a/src/icons/chef-hat.svg b/src/icons/chef-hat.svg new file mode 100644 index 0000000000..faed36db90 --- /dev/null +++ b/src/icons/chef-hat.svg @@ -0,0 +1,5 @@ + + + + diff --git a/src/icons/dollar-sign.svg b/src/icons/dollar-sign.svg new file mode 100644 index 0000000000..99b4c82ce3 --- /dev/null +++ b/src/icons/dollar-sign.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/ghost.svg b/src/icons/ghost.svg new file mode 100644 index 0000000000..f1313a2f2c --- /dev/null +++ b/src/icons/ghost.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/src/icons/index.ts b/src/icons/index.ts index b72ce0d058..5a40d27d2f 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -14,6 +14,7 @@ export { default as CautionCircleIcon } from './caution-circle.svg'; export { default as ChatIcon } from './chat-bubble.svg'; export { default as CheckCircleIcon } from './check-circle.svg'; export { default as CheckIcon } from './check.svg'; +export { default as ChefHatIcon } from './chef-hat.svg'; export { default as ChevronLeftIcon } from './chevron-left.svg'; export { default as ChevronRightIcon } from './chevron-right.svg'; export { default as ClockIcon } from './clock.svg'; @@ -28,6 +29,7 @@ export { default as DepositIcon } from './deposit.svg'; export { default as DepthChartIcon } from './depth-chart.svg'; export { default as DevicesStrokeIcon } from './devices-stroke.svg'; export { default as DiscordIcon } from './discord.svg'; +export { default as DollarSignIcon } from './dollar-sign.svg'; export { default as DoubleChevronRightIcon } from './double-chevron-right.svg'; export { default as DownloadIcon } from './download.svg'; export { default as EarthIcon } from './earth.svg'; @@ -45,6 +47,7 @@ export { default as FunkitStandardIcon } from './funkit-standard.svg'; export { default as FunkitIcon } from './funkit.svg'; export { default as GearStrokeIcon } from './gear-stroke.svg'; export { default as GearIcon } from './gear.svg'; +export { default as GhostIcon } from './ghost.svg'; export { default as GiftboxIcon } from './giftbox.svg'; export { default as GooglePlayIcon } from './google-play.svg'; export { default as GovernanceIcon } from './governance.svg'; @@ -73,7 +76,9 @@ export { default as OrderbookIcon } from './orderbook.svg'; export { default as OverviewIcon } from './overview.svg'; export { default as PencilIcon } from './pencil.svg'; export { default as Pencil2Icon } from './pencil2.svg'; +export { default as PercentSignIcon } from './percent-sign.svg'; export { default as PlayIcon } from './play.svg'; +export { default as PlusCircleIcon } from './plus-circle.svg'; export { default as PlusIcon } from './plus.svg'; export { default as PortfolioIcon } from './portfolio.svg'; export { default as PositionIcon } from './position.svg'; @@ -90,6 +95,7 @@ export { default as RewardStarIcon } from './reward-star.svg'; export { default as RewardStarsIcon } from './rewards-stars.svg'; export { default as RocketIcon } from './rocket.svg'; export { default as RoundedArrowIcon } from './rounded-arrow.svg'; +export { default as ScopeIcon } from './scope.svg'; export { default as SearchIcon } from './search.svg'; export { default as SendIcon } from './send.svg'; export { default as SettingsIcon } from './settings.svg'; @@ -98,6 +104,7 @@ export { default as ShieldStrokeIcon } from './shield-stroke.svg'; export { default as ShieldIcon } from './shield.svg'; export { default as ShowIcon } from './show.svg'; export { default as SocialXIcon } from './social-x.svg'; +export { default as SolanaSimpleIcon } from './solana-simple.svg'; export { default as SparklesIcon } from './sparkles.svg'; export { default as SpeechBubbleIcon } from './speech-bubble.svg'; export { default as StarIcon } from './star.svg'; @@ -117,16 +124,21 @@ export { default as TrendingUpIcon } from './trend-up.svg'; export { default as TriangleIcon } from './triangle.svg'; export { default as TrophyIcon } from './trophy.svg'; export { default as TryAgainIcon } from './try-again.svg'; +export { default as User2Icon } from './user-2.svg'; +export { default as UserGroupIcon } from './user-group.svg'; +export { default as UserShieldIcon } from './user-shield.svg'; export { default as UserIcon } from './user.svg'; export { default as UsersIcon } from './users.svg'; export { default as ViewfinderIcon } from './viewfinder.svg'; export { default as VolumeIcon } from './volume.svg'; +export { default as Wallet3Icon } from './wallet-3.svg'; export { default as Wallet2Icon } from './wallet2.svg'; export { default as WarningIcon } from './warning.svg'; export { default as WithdrawIcon } from './withdraw.svg'; export { default as XCircleIcon } from './x-circle.svg'; // Wallets +export { default as SolIcon } from './sol.svg'; export { default as WalletIcon } from './wallet.svg'; export { default as AppleIcon } from './wallets/apple.svg'; export { default as BitkeepIcon } from './wallets/bitkeep.svg'; diff --git a/src/icons/percent-sign.svg b/src/icons/percent-sign.svg new file mode 100644 index 0000000000..cda55b0298 --- /dev/null +++ b/src/icons/percent-sign.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/plus-circle.svg b/src/icons/plus-circle.svg new file mode 100644 index 0000000000..bc777bb0c4 --- /dev/null +++ b/src/icons/plus-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/scope.svg b/src/icons/scope.svg new file mode 100644 index 0000000000..6d01b77e2c --- /dev/null +++ b/src/icons/scope.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/src/icons/sol.svg b/src/icons/sol.svg new file mode 100644 index 0000000000..5c670ac309 --- /dev/null +++ b/src/icons/sol.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/icons/solana-simple.svg b/src/icons/solana-simple.svg new file mode 100644 index 0000000000..8b40de3134 --- /dev/null +++ b/src/icons/solana-simple.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/icons/user-2.svg b/src/icons/user-2.svg new file mode 100644 index 0000000000..8453f55c8c --- /dev/null +++ b/src/icons/user-2.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/src/icons/user-group.svg b/src/icons/user-group.svg new file mode 100644 index 0000000000..2b6cb8cf53 --- /dev/null +++ b/src/icons/user-group.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/user-shield.svg b/src/icons/user-shield.svg new file mode 100644 index 0000000000..7322d2a9cd --- /dev/null +++ b/src/icons/user-shield.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/src/icons/wallet-3.svg b/src/icons/wallet-3.svg new file mode 100644 index 0000000000..1895796d98 --- /dev/null +++ b/src/icons/wallet-3.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/hdKeyManager.ts b/src/lib/hdKeyManager.ts index 5fab0a9269..2546880710 100644 --- a/src/lib/hdKeyManager.ts +++ b/src/lib/hdKeyManager.ts @@ -1,5 +1,6 @@ /* eslint-disable max-classes-per-file */ import { LocalWallet } from '@dydxprotocol/v4-client-js'; +import { Keypair } from '@solana/web3.js'; import { Hdkey } from '@/constants/account'; @@ -59,14 +60,21 @@ class LocalWalletManager { private localNobleWallet: LocalWallet | undefined; + private localSolanaKeypair: Keypair | undefined; + setStore(store: RootStore) { this.store = store; } - setLocalWallet(localWallet: LocalWallet, localNobleWallet: LocalWallet) { + setLocalWallet( + localWallet: LocalWallet, + localNobleWallet: LocalWallet, + localSolanaKeypair: Keypair + ) { this.localWalletNonce = this.localWalletNonce != null ? this.localWalletNonce + 1 : 0; this.localWallet = localWallet; this.localNobleWallet = localNobleWallet; + this.localSolanaKeypair = localSolanaKeypair; if (!this.store) { log('LocalWalletManager: store has not been set'); @@ -92,10 +100,19 @@ class LocalWalletManager { return this.localNobleWallet; } + getLocalSolanaKeypair(localWalletNonce: number): Keypair | undefined { + if (localWalletNonce !== this.localWalletNonce) { + return undefined; + } + + return this.localSolanaKeypair; + } + clearLocalWallet() { this.localWalletNonce = undefined; this.localWallet = undefined; this.localNobleWallet = undefined; + this.localSolanaKeypair = undefined; this.store?.dispatch(setLocalWalletNonce(undefined)); } } diff --git a/src/lib/solanaConnection.ts b/src/lib/solanaConnection.ts new file mode 100644 index 0000000000..73b8f22aea --- /dev/null +++ b/src/lib/solanaConnection.ts @@ -0,0 +1,17 @@ +import { Connection as SolanaConnection } from '@solana/web3.js'; + +let solanaConnectionInstance: SolanaConnection | null = null; +let currentRpcUrl: string | null = null; + +/** + * Gets a singleton Solana connection instance. + * Creates a new connection only if the RPC URL changes. + */ +export const getSolanaConnection = (rpcUrl: string): SolanaConnection => { + if (!solanaConnectionInstance || currentRpcUrl !== rpcUrl) { + solanaConnectionInstance = new SolanaConnection(rpcUrl); + currentRpcUrl = rpcUrl; + } + + return solanaConnectionInstance; +}; diff --git a/src/lib/solanaWallet.ts b/src/lib/solanaWallet.ts new file mode 100644 index 0000000000..782b18c4ea --- /dev/null +++ b/src/lib/solanaWallet.ts @@ -0,0 +1,26 @@ +import { mnemonicToSeedSync } from '@scure/bip39'; +import { Keypair } from '@solana/web3.js'; +import { derivePath } from 'ed25519-hd-key'; + +/** + * Derives a Solana keypair from mnemonic using Ed25519 SLIP-0010 derivation. + * + * Uses the standard Solana BIP44 path: `m/44'/501'/'/0'` + * + * This implementation matches standard Solana wallets like Phantom, Solflare, etc. + * + * @param mnemonic - BIP39 mnemonic phrase (12 or 24 words) + * @param accountIndex - Account index for HD derivation path (default: 0) + * @returns Solana Keypair with derived keys + */ +export const deriveSolanaKeypairFromMnemonic = ( + mnemonic: string, + accountIndex: number = 0 +): Keypair => { + // TODO: Move to v4-client-js to consolidate wallet derivation and reduce duplicate dependencies + const seed = mnemonicToSeedSync(mnemonic); + const path = `m/44'/501'/${accountIndex}'/0'`; + const seedHex = Buffer.from(seed).toString('hex'); + const derivedSeed = derivePath(path, seedHex).key; + return Keypair.fromSeed(new Uint8Array(derivedSeed)); +}; diff --git a/src/lib/streaming/BaseSocketIOManager.ts b/src/lib/streaming/BaseSocketIOManager.ts new file mode 100644 index 0000000000..1f2af3999c --- /dev/null +++ b/src/lib/streaming/BaseSocketIOManager.ts @@ -0,0 +1,151 @@ +import { logBonsaiError, logBonsaiInfo } from '@/bonsai/logs'; +import { io, Socket } from 'socket.io-client'; + +export abstract class BaseSocketIOManager { + protected socket: Socket | null = null; + + protected subscriptions = new Map void }>>(); + + private isConnecting = false; + + private heartbeatInterval?: NodeJS.Timeout; + + constructor( + protected url: string, + protected heartbeatConfig?: { interval: number; event: string } + ) {} + + protected connect(): void { + if (this.isConnecting || (this.socket && this.socket.connected)) return; + if (!this.url) return; + + this.isConnecting = true; + + this.socket = io(this.url, { + autoConnect: true, + transports: ['websocket'], + }); + + this.socket.on('connect', () => { + logBonsaiInfo(this.constructor.name, 'connected'); + this.isConnecting = false; + this.onConnect(); + }); + + this.socket.on('disconnect', (reason) => { + logBonsaiInfo(this.constructor.name, 'disconnected', { reason }); + this.isConnecting = false; + this.onDisconnect(); + }); + + this.socket.on('connect_error', (error) => { + logBonsaiError(this.constructor.name, 'connection error', { error }); + this.isConnecting = false; + }); + + this.setupEventListeners(); + } + + protected abstract setupEventListeners(): void; + + protected onConnect(): void { + this.startHeartbeat(); + this.resubscribeAll(); + } + + protected onDisconnect(): void { + this.stopHeartbeat(); + } + + subscribe(channel: string, callback: (data: any) => void, subscriberUID?: string): () => void { + const uid = subscriberUID ?? crypto.randomUUID(); + + let handlers = this.subscriptions.get(channel); + if (!handlers) { + handlers = []; + this.subscriptions.set(channel, handlers); + + if (this.socket?.connected) { + this.sendSubscription(channel); + } + } + + handlers.push({ id: uid, callback }); + + if (!this.socket) { + this.connect(); + } + + return () => this.unsubscribe(uid); + } + + unsubscribe(subscriberUID: string): void { + const subscriptionEntries = Array.from(this.subscriptions.entries()); + + subscriptionEntries.find(([channel, handlers]) => { + const index = handlers.findIndex((h) => h.id === subscriberUID); + if (index !== -1) { + handlers.splice(index, 1); + + if (handlers.length === 0) { + this.subscriptions.delete(channel); + if (this.socket?.connected) { + this.sendUnsubscription(channel); + } + } + return true; + } + return false; + }); + } + + disconnect(): void { + this.stopHeartbeat(); + this.socket?.disconnect(); + this.socket = null; + this.subscriptions.clear(); + this.isConnecting = false; + } + + protected notifyHandlers(channel: string, data: any): void { + const handlers = this.subscriptions.get(channel); + if (!handlers) return; + + handlers.forEach((handler) => { + try { + handler.callback(data); + } catch (error) { + logBonsaiError(this.constructor.name, 'handler error', { error }); + } + }); + } + + protected abstract sendSubscription(channel: string): void; + + protected abstract sendUnsubscription(channel: string): void; + + private resubscribeAll(): void { + Array.from(this.subscriptions.keys()).forEach((channel) => { + this.sendSubscription(channel); + }); + } + + private startHeartbeat(): void { + if (!this.heartbeatConfig) return; + + this.heartbeatInterval = setInterval(() => { + this.socket?.emit(this.heartbeatConfig!.event); + }, this.heartbeatConfig.interval); + } + + private stopHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = undefined; + } + } + + get urlValue() { + return this.url; + } +} diff --git a/src/lib/streaming/spotCandleStreaming.ts b/src/lib/streaming/spotCandleStreaming.ts new file mode 100644 index 0000000000..da083fd752 --- /dev/null +++ b/src/lib/streaming/spotCandleStreaming.ts @@ -0,0 +1,120 @@ +import { SpotApiBarObject, SpotApiBarsResolution } from '@/clients/spotApi'; + +import { BaseSocketIOManager } from './BaseSocketIOManager'; + +export interface SpotCandleAggregateData { + t: number; + o: number; + h: number; + l: number; + c: number; + volume: string; + buyVolume: string; + sellVolume: string; + buyers: number; + sellers: number; + buys: number; + sells: number; + traders: number; + transactions: number; + liquidity: string; + volumeNativeToken: string; + v: string | null; +} + +export interface SpotTokenBarsUpdate { + tokenMint: string; + data: { + aggregates: { + [key: string]: { + t: number; + token: SpotCandleAggregateData; + usd: SpotCandleAggregateData; + }; + }; + eventSortKey: string; + networkId: number; + statsType: string; + timestamp: number; + tokenAddress: string; + tokenId: string; + }; +} + +const getResolutionKey = (resolution: SpotApiBarsResolution): string => { + return `r${resolution}`; +}; + +const transformAggregateToBar = (aggregate: SpotCandleAggregateData): SpotApiBarObject => { + return { + t: aggregate.t, + o: aggregate.o, + h: aggregate.h, + l: aggregate.l, + c: aggregate.c, + volume: aggregate.volume, + }; +}; + +class SpotCandleSocketManager extends BaseSocketIOManager { + protected setupEventListeners(): void { + this.socket!.on('token-bars-update', (update: SpotTokenBarsUpdate) => { + const { aggregates } = update.data; + + this.subscriptions.forEach((_, channel) => { + const [tokenMint, resolution] = channel.split(':'); + + if (tokenMint === update.tokenMint) { + const resolutionKey = getResolutionKey(resolution as SpotApiBarsResolution); + const aggregateData = aggregates[resolutionKey]; + + if (aggregateData?.usd) { + const bar = transformAggregateToBar(aggregateData.usd); + this.notifyHandlers(channel, bar); + } + } + }); + }); + } + + protected sendSubscription(channel: string): void { + const [tokenMint] = channel.split(':'); + this.socket!.emit('subscribe-token-bars', { tokenMint }); + } + + protected sendUnsubscription(channel: string): void { + const [tokenMint] = channel.split(':'); + this.socket!.emit('unsubscribe-token-bars', { tokenMint }); + } +} + +let candleManager: SpotCandleSocketManager | null = null; + +export const subscribeToSpotCandles = ( + apiUrl: string, + tokenMint: string, + resolution: SpotApiBarsResolution, + onCandleUpdate: (candle: SpotApiBarObject) => void, + subscriberUID?: string +): (() => void) => { + if (!candleManager || candleManager.urlValue !== apiUrl) { + candleManager?.disconnect(); + candleManager = new SpotCandleSocketManager(apiUrl); + } + + const channel = `${tokenMint}:${resolution}`; + return candleManager.subscribe(channel, onCandleUpdate, subscriberUID); +}; + +export const unsubscribeFromSpotStream = (subscriberUID: string) => { + if (candleManager) { + candleManager.unsubscribe(subscriberUID); + } +}; + +export const disconnectSpotStream = () => { + if (candleManager) { + candleManager.disconnect(); + candleManager = null; + } +}; diff --git a/src/lib/streaming/walletPositionsStreaming.ts b/src/lib/streaming/walletPositionsStreaming.ts new file mode 100644 index 0000000000..f2f5803832 --- /dev/null +++ b/src/lib/streaming/walletPositionsStreaming.ts @@ -0,0 +1,102 @@ +import { timeUnits } from '@/constants/time'; + +import { SpotApiTokenInfoObject } from '@/clients/spotApi'; + +import { BaseSocketIOManager } from './BaseSocketIOManager'; + +export interface SpotApiWsWalletPositionObject { + walletAddress: string; + tokenMint: string; + decimals: number; + rawBalance: string; + totalBought: number; + totalSold: number; + currentBalance: number; + totalBoughtUsd: number; + totalSoldUsd: number; + averageCostBasis: number; + realizedPnL: number; + unrealizedPnL: number; + totalPnL: number; + firstTradeAt: string; + lastTradeAt: string; + tradeCount: number; + tokenData?: SpotApiTokenInfoObject; + unrealizedValueUsd: number; +} + +export interface SpotApiWsWalletBalanceObject { + mint: string; + amount: number; + decimals: number; + rawAmount: string; + priceUsd: number; + usdValue: number; +} + +export interface SpotApiWsWalletPositionsUpdate { + walletAddress: string; + positions: SpotApiWsWalletPositionObject[]; + tokenBalances: SpotApiWsWalletBalanceObject[]; + totalPnL: number; + totalRealizedPnL: number; + totalUnrealizedPnL: number; + totalValueUsd: number; + solBalance: number; + solPriceUsd: number; + solValueUsd: number; + lastUpdated: string; +} + +const HEARTBEAT_INTERVAL = timeUnits.minute * 5; + +class WalletPositionsSocketManager extends BaseSocketIOManager { + constructor(apiUrl: string) { + super(apiUrl, { + interval: HEARTBEAT_INTERVAL, + event: 'wallet-ping', + }); + } + + protected setupEventListeners(): void { + this.socket!.on('wallet-positions-update', (data: SpotApiWsWalletPositionsUpdate) => { + this.notifyHandlers(data.walletAddress, data); + }); + } + + protected sendSubscription(walletAddress: string): void { + this.socket!.emit('subscribe-positions', { channel: walletAddress }); + } + + protected sendUnsubscription(walletAddress: string): void { + this.socket!.emit('unsubscribe-positions', { channel: walletAddress }); + } +} + +let walletManager: WalletPositionsSocketManager | null = null; + +export const subscribeToWalletPositions = ( + apiUrl: string, + walletAddress: string, + onUpdate: (data: SpotApiWsWalletPositionsUpdate) => void +): (() => void) => { + if (!walletManager || walletManager.urlValue !== apiUrl) { + walletManager?.disconnect(); + walletManager = new WalletPositionsSocketManager(apiUrl); + } + + return walletManager.subscribe(walletAddress, onUpdate); +}; + +export const unsubscribeFromWalletPositions = (subscriberUID: string) => { + if (walletManager) { + walletManager.unsubscribe(subscriberUID); + } +}; + +export const disconnectWalletPositionsStream = () => { + if (walletManager) { + walletManager.disconnect(); + walletManager = null; + } +}; diff --git a/src/lib/tradingView/spotDatafeed/candleService.ts b/src/lib/tradingView/spotDatafeed/candleService.ts deleted file mode 100644 index 88b59e9a2b..0000000000 --- a/src/lib/tradingView/spotDatafeed/candleService.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { logBonsaiError } from '@/bonsai/logs'; - -import { SpotCandleData, SpotCandleServiceQuery } from './types'; -import { transformSpotCandlesForChart } from './utils'; - -export class SpotCandleServiceClient { - private host: string; - - constructor(host: string) { - if (!host) { - logBonsaiError('SpotCandleServiceClient', 'host not configured'); - } - this.host = host; - } - - private async _get(endpoint: string, params?: Record): Promise { - const url = new URL(`${this.host}/${endpoint}`); - - if (params) { - Object.entries(params).forEach(([key, value]) => { - url.searchParams.append(key, value.toString()); - }); - } - - const response = await fetch(url.toString(), { - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return response.json(); - } - - async getCandles(params: SpotCandleServiceQuery) { - const response = await this._get(`ohlcv/${params.token}`, { - interval: params.interval, - from: params.from.toString(), - ...(params.to && { to: params.to.toString() }), - }); - - return transformSpotCandlesForChart(response); - } - - get url() { - return this.host; - } -} - -let candleServiceClient: SpotCandleServiceClient | null = null; - -const getOrCreateCandleServiceClient = (apiUrl: string): SpotCandleServiceClient => { - if (!candleServiceClient || candleServiceClient.url !== apiUrl) { - candleServiceClient = new SpotCandleServiceClient(apiUrl); - } - return candleServiceClient; -}; - -export const getSpotCandleData = async (apiUrl: string, params: SpotCandleServiceQuery) => { - const client = getOrCreateCandleServiceClient(apiUrl); - return client.getCandles(params); -}; diff --git a/src/lib/tradingView/spotDatafeed/index.ts b/src/lib/tradingView/spotDatafeed/index.ts index 3d56b4a87d..9b796956c0 100644 --- a/src/lib/tradingView/spotDatafeed/index.ts +++ b/src/lib/tradingView/spotDatafeed/index.ts @@ -1,13 +1,22 @@ import { wrapAndLogBonsaiError } from '@/bonsai/logs'; +import { BonsaiCore } from '@/bonsai/ontology'; import type { DatafeedConfiguration, IBasicDataFeed } from 'public/tradingview/charting_library'; -import { getSpotCandleData } from './candleService'; -import { subscribeToSpotStream, unsubscribeFromSpotStream } from './streaming'; -import { SpotCandleServiceQuery } from './types'; +import { type RootStore } from '@/state/_store'; + +import { getSpotBars, SpotApiGetBarsQuery } from '@/clients/spotApi'; +import { waitForSelector } from '@/lib/asyncUtils'; +import { + subscribeToSpotCandles, + unsubscribeFromSpotStream, +} from '@/lib/streaming/spotCandleStreaming'; + import { createSpotSymbolInfo, resolutionToSpotInterval, SPOT_SUPPORTED_RESOLUTIONS, + transformSpotCandleForChart, + transformSpotCandlesForChart, } from './utils'; const configurationData: DatafeedConfiguration = { @@ -28,7 +37,7 @@ const configurationData: DatafeedConfiguration = { ], }; -export const getSpotDatafeed = (spotApiUrl: string): IBasicDataFeed => ({ +export const getSpotDatafeed = (store: RootStore, spotApiUrl: string): IBasicDataFeed => ({ onReady: (cb) => { setTimeout(() => cb(configurationData), 0); }, @@ -38,14 +47,19 @@ export const getSpotDatafeed = (spotApiUrl: string): IBasicDataFeed => ({ onResultReadyCallback([]); }, - resolveSymbol: async (tokenSymbol, onSymbolResolvedCallback) => { - const symbolInfo = createSpotSymbolInfo(tokenSymbol); + resolveSymbol: async (tokenMint, onSymbolResolvedCallback) => { + const tokenPrice = await waitForSelector(store, (s) => BonsaiCore.spot.tokenPrice.data(s)); + const symbolInfo = createSpotSymbolInfo(tokenMint, tokenPrice); setTimeout(() => onSymbolResolvedCallback(symbolInfo), 0); }, getBars: async (symbolInfo, resolution, periodParams, onHistoryCallback, onErrorCallback) => { const { from, to } = periodParams; + // Clamp to parameter to current time to prevent API from returning future placeholder candles + const currentTimeSeconds = Math.floor(Date.now() / 1000) + 1; + const clampedTo = Math.min(to, currentTimeSeconds); + if (!symbolInfo.ticker) { const error = new Error('Symbol ticker is required'); onErrorCallback(error.message); @@ -53,27 +67,26 @@ export const getSpotDatafeed = (spotApiUrl: string): IBasicDataFeed => ({ } try { - const token = symbolInfo.ticker; + const tokenMint = symbolInfo.ticker; const interval = resolutionToSpotInterval(resolution); - const fromMs = from * 1000; - const toMs = to * 1000; - - const query: SpotCandleServiceQuery = { - token, - interval, - from: fromMs, - to: toMs, + + const query: SpotApiGetBarsQuery = { + tokenMint, + resolution: interval, + from, + to: clampedTo, }; const bars = await wrapAndLogBonsaiError( - () => getSpotCandleData(spotApiUrl, query), - 'getSpotCandleData' + () => getSpotBars(spotApiUrl, query), + 'getSpotBars' )(); if (bars.length === 0) { onHistoryCallback([], { noData: true }); } else { - onHistoryCallback(bars, { noData: false }); + const chartBars = transformSpotCandlesForChart(bars); + onHistoryCallback(chartBars, { noData: false }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; @@ -83,8 +96,20 @@ export const getSpotDatafeed = (spotApiUrl: string): IBasicDataFeed => ({ subscribeBars: (symbolInfo, resolution, onRealtimeCallback, subscriberUID) => { if (!symbolInfo.ticker) return; - const token = symbolInfo.ticker; - subscribeToSpotStream(spotApiUrl, token, resolution, onRealtimeCallback, subscriberUID); + + const tokenMint = symbolInfo.ticker; + const interval = resolutionToSpotInterval(resolution); + + subscribeToSpotCandles( + spotApiUrl, + tokenMint, + interval, + (bar) => { + const chartBar = transformSpotCandleForChart(bar); + onRealtimeCallback(chartBar); + }, + subscriberUID + ); }, unsubscribeBars: (subscriberUID) => { diff --git a/src/lib/tradingView/spotDatafeed/streaming.ts b/src/lib/tradingView/spotDatafeed/streaming.ts deleted file mode 100644 index a75b2c658b..0000000000 --- a/src/lib/tradingView/spotDatafeed/streaming.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { logBonsaiError, logBonsaiInfo } from '@/bonsai/logs'; -import type { ResolutionString, SubscribeBarsCallback } from 'public/tradingview/charting_library'; -import { io, Socket } from 'socket.io-client'; - -import { - SpotCandleServiceInterval, - SpotStreamingHandler, - SpotStreamingSubscription, - WsSpotCandleUpdate, -} from './types'; -import { resolutionToSpotInterval, transformSpotCandleForChart } from './utils'; - -class SpotStreamingManager { - private socket: Socket | null = null; - - private subscriptions = new Map(); - - private isConnecting = false; - - constructor(private apiUrl: string) { - if (!apiUrl) { - logBonsaiError('SpotStreamingManager', 'API URL not configured'); - } - } - - private async connect(): Promise { - if (this.isConnecting || (this.socket && this.socket.connected)) { - return; - } - - if (!this.apiUrl) { - logBonsaiError('SpotStreamingManager', 'API URL not configured'); - return; - } - - this.isConnecting = true; - logBonsaiInfo('SpotStreamingManager', 'connecting', { apiUrl: this.apiUrl }); - - try { - this.socket = io(this.apiUrl, { - autoConnect: true, - transports: ['websocket'], - }); - - this.socket.on('connect', () => { - logBonsaiInfo('SpotStreamingManager', 'connected'); - this.isConnecting = false; - this.resubscribeAll(); - }); - - this.socket.on('disconnect', (reason) => { - logBonsaiInfo('SpotStreamingManager', 'disconnected', { reason }); - this.isConnecting = false; - }); - - this.socket.on('connect_error', (error) => { - logBonsaiError('SpotStreamingManager', 'connection error', { error }); - this.isConnecting = false; - }); - - this.socket.on('candle', (update: WsSpotCandleUpdate) => { - this.handleCandleUpdate(update); - }); - } catch (error) { - logBonsaiError('SpotStreamingManager', 'connection failed', { error }); - this.isConnecting = false; - } - } - - private resubscribeAll(): void { - logBonsaiInfo('SpotStreamingManager', 'resubscribing all channels', { - subscriptionCount: this.subscriptions.size, - }); - - Array.from(this.subscriptions.values()).forEach((subscription) => { - this.sendSubscription(subscription.token, subscription.interval); - }); - } - - private sendSubscription(token: string, interval: SpotCandleServiceInterval): void { - if (!this.socket || !this.socket.connected) { - logBonsaiError('SpotStreamingManager', 'socket not ready (subscribe)'); - return; - } - - const subscriptionData = { token, interval }; - - logBonsaiInfo('SpotStreamingManager', 'subscribing', subscriptionData); - this.socket.emit('subscribe', subscriptionData); - } - - private sendUnsubscription(token: string, interval: SpotCandleServiceInterval): void { - if (!this.socket || !this.socket.connected) { - logBonsaiError('SpotStreamingManager', 'socket not ready (unsubscribe)'); - return; - } - - const unsubscriptionData = { token, interval }; - - logBonsaiInfo('SpotStreamingManager', 'unsubscribing', unsubscriptionData); - this.socket.emit('unsubscribe', unsubscriptionData); - } - - private handleCandleUpdate(update: WsSpotCandleUpdate): void { - try { - const { token, interval } = update; - const channelKey = `${token}:${interval}`; - - const subscription = this.subscriptions.get(channelKey); - if (!subscription) { - return; - } - - const bar = transformSpotCandleForChart(update.candle); - - logBonsaiInfo('SpotStreamingManager', 'received candle update', { - token, - interval, - handlerCount: subscription.handlers.length, - ...bar, - }); - - subscription.handlers.forEach((handler) => { - try { - handler.callback(bar); - } catch (error) { - logBonsaiError('SpotStreamingManager', 'streaming handler error', { - error, - }); - } - }); - } catch (error) { - logBonsaiError('SpotStreamingManager', 'failed to handle candle update', { - error, - }); - } - } - - subscribe( - token: string, - resolution: ResolutionString, - onRealtimeCallback: SubscribeBarsCallback, - subscriberUID: string - ): void { - const interval = resolutionToSpotInterval(resolution); - const channelKey = `${token}:${interval}`; - - logBonsaiInfo('SpotStreamingManager', 'subscription requested', { - channelKey, - subscriberUID, - }); - - const handler: SpotStreamingHandler = { - id: subscriberUID, - callback: onRealtimeCallback, - }; - - let subscription = this.subscriptions.get(channelKey); - if (subscription) { - subscription.handlers.push(handler); - logBonsaiInfo('SpotStreamingManager', 'added handler', { channelKey, subscriberUID }); - return; - } - - subscription = { - token, - interval, - handlers: [handler], - }; - - this.subscriptions.set(channelKey, subscription); - - this.sendSubscription(token, interval); - - if (!this.socket) { - this.connect(); - } - } - - unsubscribe(subscriberUID: string): void { - logBonsaiInfo('SpotStreamingManager', 'unsubscribe requested', { subscriberUID }); - - const subscriptionEntries = Array.from(this.subscriptions.entries()); - - subscriptionEntries.find(([channelKey, subscription]) => { - const handlerIndex = subscription.handlers.findIndex((h) => h.id === subscriberUID); - - if (handlerIndex !== -1) { - subscription.handlers.splice(handlerIndex, 1); - logBonsaiInfo('SpotStreamingManager', 'removed handler', { channelKey, subscriberUID }); - - if (subscription.handlers.length === 0) { - logBonsaiInfo('SpotStreamingManager', 'unsubscribing channel', { - channelKey, - subscriberUID, - }); - this.sendUnsubscription(subscription.token, subscription.interval); - this.subscriptions.delete(channelKey); - } - return true; - } - return false; - }); - } - - disconnect(): void { - logBonsaiInfo('SpotStreamingManager', 'disconnecting'); - - if (this.socket) { - this.socket.disconnect(); - this.socket = null; - } - this.subscriptions.clear(); - this.isConnecting = false; - } - - get url() { - return this.apiUrl; - } -} - -let streamingManager: SpotStreamingManager | null = null; - -const getOrCreateStreamingManager = (apiUrl: string): SpotStreamingManager => { - if (!streamingManager || streamingManager.url !== apiUrl) { - if (streamingManager) { - streamingManager.disconnect(); - } - streamingManager = new SpotStreamingManager(apiUrl); - } - return streamingManager; -}; - -export const subscribeToSpotStream = ( - apiUrl: string, - token: string, - resolution: ResolutionString, - onRealtimeCallback: SubscribeBarsCallback, - subscriberUID: string -) => { - const manager = getOrCreateStreamingManager(apiUrl); - manager.subscribe(token, resolution, onRealtimeCallback, subscriberUID); -}; - -export const unsubscribeFromSpotStream = (subscriberUID: string) => { - if (streamingManager) { - streamingManager.unsubscribe(subscriberUID); - } -}; - -export const disconnectSpotStream = () => { - if (streamingManager) { - streamingManager.disconnect(); - streamingManager = null; - } -}; diff --git a/src/lib/tradingView/spotDatafeed/types.ts b/src/lib/tradingView/spotDatafeed/types.ts deleted file mode 100644 index e375cf8c78..0000000000 --- a/src/lib/tradingView/spotDatafeed/types.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { SubscribeBarsCallback } from 'public/tradingview/charting_library'; - -export type SpotCandleServiceInterval = - | '1S' - | '5S' - | '15S' - | '30S' - | '1' - | '5' - | '15' - | '30' - | '60' - | '240' - | '720' - | '1D' - | '7D'; - -export type SpotCandleData = { - t: number; // timestamp - o: number; // open - h: number; // high - l: number; // low - c: number; // close - v: number; // volume - v_usd: number; // volume in USD -}; - -export type SpotCandleServiceQuery = { - token: string; - interval: SpotCandleServiceInterval; - from: string | number; // ISO 8601 or UNIX milliseconds - to?: string | number; // ISO 8601 or UNIX milliseconds -}; - -export type WsSpotCandleUpdate = { - candle: SpotCandleData; - interval: SpotCandleServiceInterval; - timestamp: number; - token: string; -}; - -export type SpotStreamingSubscription = { - token: string; - interval: SpotCandleServiceInterval; - handlers: SpotStreamingHandler[]; -}; - -export type SpotStreamingHandler = { - id: string; - callback: SubscribeBarsCallback; -}; diff --git a/src/lib/tradingView/spotDatafeed/utils.ts b/src/lib/tradingView/spotDatafeed/utils.ts index 0ca3851d0e..9700bb7970 100644 --- a/src/lib/tradingView/spotDatafeed/utils.ts +++ b/src/lib/tradingView/spotDatafeed/utils.ts @@ -1,59 +1,71 @@ import { DateTime } from 'luxon'; import type { - Bar, LibrarySymbolInfo, ResolutionString, Timezone, } from 'public/tradingview/charting_library'; -import { RESOLUTION_TO_SPOT_INTERVAL_MAP } from '@/constants/candles'; +import { RESOLUTION_TO_SPOT_INTERVAL_MAP, TradingViewBar } from '@/constants/candles'; +import { SMALL_USD_DECIMALS } from '@/constants/numbers'; +import { SpotApiBarObject, SpotApiBarsResolution } from '@/clients/spotApi'; import { objectKeys } from '@/lib/objectHelpers'; -import { SpotCandleData, SpotCandleServiceInterval } from './types'; - const timezone = DateTime.local().get('zoneName') as unknown as Timezone; -// Convert TradingView resolution to spot candle service interval -export const resolutionToSpotInterval = ( - resolution: ResolutionString -): SpotCandleServiceInterval => { +// Convert TradingView resolution to spot bars resolution +export const resolutionToSpotInterval = (resolution: ResolutionString): SpotApiBarsResolution => { return RESOLUTION_TO_SPOT_INTERVAL_MAP[resolution] ?? '1D'; }; // Supported resolutions for spot charts export const SPOT_SUPPORTED_RESOLUTIONS = objectKeys(RESOLUTION_TO_SPOT_INTERVAL_MAP); -// Transform single candle item for chart consumption -export const transformSpotCandleForChart = (candle: SpotCandleData): Bar => { +// Transform single bar item for chart consumption +export const transformSpotCandleForChart = (bar: SpotApiBarObject): TradingViewBar => { return { - time: candle.t * 1000, // Convert to milliseconds - open: candle.o, - high: candle.h, - low: candle.l, - close: candle.c, - volume: candle.v_usd, + time: bar.t * 1000, // Convert to milliseconds + open: bar.o, + high: bar.h, + low: bar.l, + close: bar.c, + volume: parseFloat(bar.volume), }; }; -// Transform array of candle data for chart consumption -export const transformSpotCandlesForChart = (candles: SpotCandleData[]): Bar[] => { - return candles.map(transformSpotCandleForChart); +// Transform array of bar data for chart consumption +export const transformSpotCandlesForChart = (bars: SpotApiBarObject[]): TradingViewBar[] => { + return bars.map(transformSpotCandleForChart); +}; + +const getSpotPriceDecimals = (price?: number | null): number => { + if (!price || price <= 0) return 6; + + const magnitude = Math.floor(Math.log10(Math.abs(price))); + const decimals = Math.max(0, SMALL_USD_DECIMALS - magnitude - 1); + + return Math.min(decimals, 10); }; // Create symbol info for spot tokens -export const createSpotSymbolInfo = (tokenSymbol: string): LibrarySymbolInfo => { +export const createSpotSymbolInfo = ( + tokenMint: string, + tokenPrice?: number | null +): LibrarySymbolInfo => { + const decimals = getSpotPriceDecimals(tokenPrice); + const pricescale = 10 ** decimals; + return { - ticker: tokenSymbol, - name: tokenSymbol, - description: tokenSymbol, + ticker: tokenMint, + name: tokenMint, + description: tokenMint, type: 'crypto', session: '24x7', timezone, exchange: 'Spot', listed_exchange: 'Spot', minmov: 1, - pricescale: 1000000, + pricescale, has_intraday: true, has_daily: true, has_weekly_and_monthly: true, diff --git a/src/pages/spot/QuickButtons.tsx b/src/pages/spot/QuickButtons.tsx new file mode 100644 index 0000000000..5708179ea2 --- /dev/null +++ b/src/pages/spot/QuickButtons.tsx @@ -0,0 +1,204 @@ +import { ReactNode, useEffect, useState } from 'react'; + +import { NumericFormat } from 'react-number-format'; +import styled, { css } from 'styled-components'; + +import { Icon, IconName } from '@/components/Icon'; + +type ValidationConfig = { + min?: number; + max?: number; + decimalScale?: number; +}; + +export type QuickButtonProps = { + options: string[]; + onSelect?: (value: string) => void; + onOptionsEdit?: (options: string[]) => void; + currentValue?: string; + disabled?: boolean; + validation?: ValidationConfig; + prefix?: string; + suffix?: string; + slotRight?: ReactNode; +}; + +export const QuickButtons = ({ + options, + onSelect, + onOptionsEdit, + currentValue, + disabled, + validation, + prefix, + suffix, + slotRight, +}: QuickButtonProps) => { + const [isEditing, setIsEditing] = useState(false); + const [editValues, setEditValues] = useState(options); + + const handleEdit = () => { + setEditValues(options.map((o) => o.toString())); + setIsEditing(true); + }; + + const handleConfirmEdit = () => { + const validatedValues = editValues.map((value, i) => { + const numValue = parseFloat(value); + + if (Number.isNaN(numValue)) { + return options[i]!; + } + + let clamped = numValue; + if (validation?.min !== undefined) clamped = Math.max(clamped, validation.min); + if (validation?.max !== undefined) clamped = Math.min(clamped, validation.max); + const clampedString = clamped.toString(); + + const isDuplicate = options.includes(clampedString) && options.indexOf(clampedString) !== i; + if (isDuplicate) { + return options[i]!; + } + + return clampedString; + }); + + onOptionsEdit?.(validatedValues); + setIsEditing(false); + }; + + useEffect(() => { + setEditValues(options); + }, [options]); + + const handleInputChange = (index: number, value: string) => { + const newEditValues = [...editValues]; + newEditValues[index] = value; + setEditValues(newEditValues); + }; + + return ( + <$QuickButtonsContainer> + {options.map((option, i) => + isEditing ? ( + <$InputQuickButtonContainer key={option}> + <$QuickButtonInput + allowNegative={false} + decimalScale={validation?.decimalScale ?? 2} + disabled={disabled} + value={editValues[i]} + onValueChange={(values: { value: string }) => handleInputChange(i, values.value)} + prefix={prefix} + suffix={suffix} + /> + + ) : ( + <$QuickButtonContainer + key={option} + onClick={() => onSelect?.(option)} + type="button" + isSelected={currentValue === option} + disabled={disabled} + > + + {prefix} + {option} + {suffix} + + {slotRight} + + ) + )} + <$QuickButtonContainer + aria-label={isEditing ? 'Confirm' : 'Edit'} + type="button" + onClick={isEditing ? handleConfirmEdit : handleEdit} + disabled={disabled} + isEditing={isEditing} + > + + + + ); +}; + +const $QuickButtonInput = styled(NumericFormat)` + outline: 0; + border: 0; + min-width: 0; + background-color: transparent; + text-align: center; +`; + +const $QuickButtonsContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + height: 2.3125rem; + gap: 0.75rem; + + & > *:not(:last-child) { + flex: 1; + } + + & > *:last-child { + aspect-ratio: 1; + flex-shrink: 0; + } +`; + +const QuickButtonContainerStyles = css` + display: inline-flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 0.25rem; + height: 100%; + justify-content: center; + border-radius: 0.5rem; + border: 1px solid var(--color-layer-4); + padding: 0 0.5rem; + font: var(--font-base-medium); + color: var(--color-text-1); + min-width: 0; + + @media (prefers-reduced-motion: no-preference) { + transition: 0.3s var(--ease-out-expo); + } +`; + +const $InputQuickButtonContainer = styled.div` + ${QuickButtonContainerStyles} + + background-color: var(--color-layer-3); + + &:focus-within { + background-color: var(--color-layer-4); + border-color: var(--color-layer-5); + color: var(--color-text-2); + } +`; + +const $QuickButtonContainer = styled.button<{ isSelected?: boolean; isEditing?: boolean }>` + ${QuickButtonContainerStyles} + + ${({ isSelected }) => + isSelected && + css` + background-color: var(--color-layer-4); + `} + + ${({ isEditing }) => + isEditing && + css` + background-color: var(--color-accent-faded); + color: var(--color-accent); + border-color: var(--color-accent); + `} + + @media (hover: hover) { + &:hover { + background-color: var(--color-layer-3); + } + } +`; diff --git a/src/pages/spot/Spot.tsx b/src/pages/spot/Spot.tsx index b181887306..1a35b22327 100644 --- a/src/pages/spot/Spot.tsx +++ b/src/pages/spot/Spot.tsx @@ -1,36 +1,294 @@ -import { useParams } from 'react-router-dom'; +import { useCallback, useMemo, useState } from 'react'; + +import { SpotSide } from '@/bonsai/forms/spot'; +import { BonsaiCore } from '@/bonsai/ontology'; +import { keyBy } from 'lodash'; +import { useNavigate, useParams } from 'react-router-dom'; import styled, { css } from 'styled-components'; -import { TradeLayouts } from '@/constants/layout'; +import { + HORIZONTAL_PANEL_MAX_HEIGHT, + HORIZONTAL_PANEL_MIN_HEIGHT, + TradeLayouts, +} from '@/constants/layout'; +import { SPOT_DUST_USD_THRESHOLD } from '@/constants/spot'; + +import { useCurrentSpotToken } from '@/hooks/useCurrentSpotToken'; +import { useSpotTokenSearch } from '@/hooks/useSpotTokenSearch'; import breakpoints from '@/styles/breakpoints'; import { layoutMixins } from '@/styles/layoutMixins'; +import { IconName } from '@/components/Icon'; +import { Output, OutputType } from '@/components/Output'; import { SpotTvChart } from '@/views/charts/TradingView/SpotTvChart'; -import { useAppSelector } from '@/state/appTypes'; +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { setHorizontalPanelHeightPx } from '@/state/appUiConfigs'; +import { getHorizontalPanelHeightPx } from '@/state/appUiConfigsSelectors'; import { getSelectedTradeLayout } from '@/state/layoutSelectors'; +import { spotFormActions } from '@/state/spotForm'; + +import { mapIfPresent } from '@/lib/do'; +import { MustNumber } from '@/lib/numbers'; +import { isPresent } from '@/lib/typeUtils'; + +import { useResizablePanel } from '../trade/useResizablePanel'; +import { SpotHeader } from './SpotHeader'; +import { type SpotPositionItem } from './SpotHoldingsTable'; +import { SpotHorizontalPanel } from './SpotHorizontalPanel'; +import { SpotTokenInfo, TokenInfoItem, TokenInfoLink } from './SpotTokenInfo'; +import { SpotTradeForm } from './SpotTradeForm'; +import { type SpotTradeItem } from './SpotTradesTable'; +import { SpotHeaderToken } from './types'; + +// TODO: spot localization const SpotPage = () => { - const { symbol } = useParams<{ symbol: string }>(); + const { tokenMint } = useParams<{ tokenMint: string }>(); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const tradeLayout = useAppSelector(getSelectedTradeLayout); + const tokenMetadata = useAppSelector(BonsaiCore.spot.tokenMetadata.data); + const tokenPrice = useAppSelector(BonsaiCore.spot.tokenPrice.data); + const tokenBalances = useAppSelector(BonsaiCore.spot.walletPositions.tokenBalances); + const walletPositions = useAppSelector(BonsaiCore.spot.walletPositions.positions); + const portfolioTrades = useAppSelector(BonsaiCore.spot.portfolioTrades.data); + + const isTokenMetadataLoading = + useAppSelector(BonsaiCore.spot.tokenMetadata.loading) !== 'success'; + const isWalletPositionsLoading = + useAppSelector(BonsaiCore.spot.walletPositions.loading) !== 'success'; + const isPortfolioTradesLoading = + useAppSelector(BonsaiCore.spot.portfolioTrades.loading) !== 'success'; + + const horizontalPanelHeightPxBase = useAppSelector(getHorizontalPanelHeightPx); + const setPanelHeight = useCallback( + (h: number) => { + dispatch(setHorizontalPanelHeightPx(h)); + }, + [dispatch] + ); + const [isHorizontalOpen, setIsHorizontalOpen] = useState(true); + const { + handleMouseDown, + panelHeight: horizontalPanelHeight, + isDragging, + } = useResizablePanel(horizontalPanelHeightPxBase, setPanelHeight, { + min: HORIZONTAL_PANEL_MIN_HEIGHT, + max: HORIZONTAL_PANEL_MAX_HEIGHT, + }); + + const [searchQuery, setSearchQuery] = useState(''); + + const { data: searchResults, isPending: isSearchLoading } = useSpotTokenSearch(searchQuery); + + useCurrentSpotToken(); + + const holdings: SpotPositionItem[] = useMemo(() => { + const positionsByMint = keyBy(walletPositions, 'tokenMint'); + + const res: SpotPositionItem[] = tokenBalances + .map((tokenBalance) => { + const position = positionsByMint[tokenBalance.mint]; + + return { + holdingsAmount: tokenBalance.amount, + holdingsUsd: tokenBalance.usdValue, + tokenAddress: tokenBalance.mint, + tokenName: position?.tokenData?.tokenNameFull ?? position?.tokenData?.symbol ?? 'Unknown', + tokenSymbol: position?.tokenData?.symbol ?? 'Unknown', + tokenImage: position?.tokenData?.image, + boughtAmount: position?.totalBought, + boughtUsd: position?.totalBoughtUsd, + soldAmount: position?.totalSold, + soldUsd: position?.totalSoldUsd, + pnlUsd: position?.unrealizedPnL, + avgEntryUsd: position?.averageCostBasis, + }; + }) + .filter((holding) => holding.holdingsUsd >= SPOT_DUST_USD_THRESHOLD); + + return res; + }, [tokenBalances, walletPositions]); + + const trades: SpotTradeItem[] = useMemo(() => { + return portfolioTrades.trades.map((trade) => { + const tokenData = portfolioTrades.tokenData[trade.tokenMint]; + + return { + id: trade.id, + side: trade.side, + tokenAmount: trade.tokenAmount, + usdValue: trade.usdValue, + txHash: trade.txHash, + createdAt: trade.createdAt, + tokenSymbol: tokenData?.symbol, + tokenImage: tokenData?.image, + marketCapUsd: mapIfPresent( + tokenData?.circulatingSupply, + (circulatingSupply) => MustNumber(circulatingSupply) * trade.tokenPriceUsd + ), + }; + }); + }, [portfolioTrades]); + + const currentTokenData = useMemo(() => { + if (!tokenMetadata || tokenPrice == null || !tokenMint) return null; + + return { + name: tokenMetadata.tokenNameFull ?? tokenMetadata.symbol ?? 'Unknown', + symbol: tokenMetadata.symbol ?? 'Unknown', + tokenAddress: tokenMint, + buys24hUsd: tokenMetadata.token24hBuys, + sells24hUsd: -MustNumber(tokenMetadata.token24hSells), + change24hPercent: tokenMetadata.pricePercentChange24h, + circulatingSupply: MustNumber(tokenMetadata.circulatingSupply), + liquidityUsd: tokenMetadata.liquidityUSD, + logoUrl: tokenMetadata.image, + marketCapUsd: MustNumber(tokenMetadata.circulatingSupply) * tokenPrice, + fdvUsd: MustNumber(tokenMetadata.totalSupply) * tokenPrice, + priceUsd: tokenPrice, + totalSupply: MustNumber(tokenMetadata.totalSupply), + volume24hUsd: tokenMetadata.volumeUSD, + holders: tokenMetadata.holders, + top10HoldersPercent: mapIfPresent(tokenMetadata.top10HoldersPercent, (v) => v / 100), + devHoldingPercent: mapIfPresent(tokenMetadata.devHoldingPercent, (v) => v / 100), + snipersPercent: mapIfPresent(tokenMetadata.snipersPercent, (v) => v / 100), + bundlersPercent: mapIfPresent(tokenMetadata.bundlersPercent, (v) => v / 100), + insidersPercent: mapIfPresent(tokenMetadata.insidersPercent, (v) => v / 100), + createdAt: mapIfPresent(tokenMetadata.createdAt, (v) => new Date(v * 1000)), + }; + }, [tokenMint, tokenMetadata, tokenPrice]); + + const tokenLinks = useMemo(() => { + const { socialLinks } = tokenMetadata ?? {}; + + const links: TokenInfoLink[] = [ + { icon: IconName.Earth, url: socialLinks?.website }, + { icon: IconName.CoinMarketCap, url: socialLinks?.coinmarketcap }, + { icon: IconName.SocialX, url: socialLinks?.twitter }, + ].filter((v): v is TokenInfoLink => isPresent(v.url)); + + return links; + }, [tokenMetadata]); + + const tokenInfoItems: TokenInfoItem[] = useMemo( + () => [ + { + key: 'holders', + iconName: IconName.UserGroup, + label: 'Holders', + value: <$Output type={OutputType.CompactNumber} value={currentTokenData?.holders} />, + }, + { + key: 'top10', + iconName: IconName.User2, + label: 'Top 10', + value: <$Output type={OutputType.Percent} value={currentTokenData?.top10HoldersPercent} />, + }, + { + key: 'devHolding', + iconName: IconName.ChefHat, + label: 'Dev Holding', + value: <$Output type={OutputType.Percent} value={currentTokenData?.devHoldingPercent} />, + }, + { + key: 'snipers', + iconName: IconName.Scope, + label: 'Snipers', + value: <$Output type={OutputType.Percent} value={currentTokenData?.snipersPercent} />, + }, + { + key: 'bundlers', + iconName: IconName.Ghost, + label: 'Bundlers', + value: <$Output type={OutputType.Percent} value={currentTokenData?.bundlersPercent} />, + }, + { + key: 'insiders', + iconName: IconName.Warning, + label: 'Insiders', + value: <$Output type={OutputType.Percent} value={currentTokenData?.insidersPercent} />, + }, + ], + [ + currentTokenData?.bundlersPercent, + currentTokenData?.devHoldingPercent, + currentTokenData?.holders, + currentTokenData?.insidersPercent, + currentTokenData?.snipersPercent, + currentTokenData?.top10HoldersPercent, + ] + ); + + const handleTokenSelect = (token: SpotHeaderToken) => { + navigate(`/spot/${token.tokenAddress}`); + setSearchQuery(''); + }; + + const handlePositionSelect = (token: SpotPositionItem) => { + navigate(`/spot/${token.tokenAddress}`); + }; + + const handleTokenSearchChange = (value: string) => { + setSearchQuery(value); + }; + + const handlePositionSell = (token: SpotPositionItem) => { + dispatch(spotFormActions.setSide(SpotSide.SELL)); + navigate(`/spot/${token.tokenAddress}`); + }; + + if (!tokenMint) return null; + return ( - <$SpotLayout tradeLayout={tradeLayout}> + <$SpotLayout + tradeLayout={tradeLayout} + isHorizontalOpen={isHorizontalOpen} + horizontalPanelHeightPx={horizontalPanelHeight} + >
-
Spot Market Selector (Coming Soon)
+
- <$GridSection gridArea="Side"> -
Spot Side Panel (Coming Soon)
- + <$SideGridSection gridArea="Side"> + + + <$GridSection gridArea="Inner"> - + + {isDragging && <$CoverUpTradingView />} <$GridSection gridArea="Horizontal"> -
Spot Horizontal Panel (Coming Soon)
+ ); @@ -40,25 +298,27 @@ export default SpotPage; const $SpotLayout = styled.article<{ tradeLayout: TradeLayouts; + isHorizontalOpen: boolean; + horizontalPanelHeightPx: number; }>` + --horizontalPanel-height: ${({ horizontalPanelHeightPx }) => `${horizontalPanelHeightPx}px`}; + /* prettier-ignore */ --layout-default: 'Top Top' auto 'Inner Side' minmax(0, 1fr) - 'Horizontal Side' 200px - / 1fr var(--sidebar-width); + 'Horizontal Side' minmax(var(--tabs-height), var(--horizontalPanel-height)) + / 1fr var(--spot-sidebar-width); /* prettier-ignore */ --layout-default-desktopMedium: 'Top Side' auto 'Inner Side' minmax(0, 1fr) - 'Horizontal Side' 200px - / 1fr var(--sidebar-width); + 'Horizontal Side' minmax(var(--tabs-height), var(--horizontalPanel-height)) + / 1fr var(--spot-sidebar-width); - // Props/defaults --layout: var(--layout-default); - // Variants @media ${breakpoints.desktopMedium} { --layout: var(--layout-default-desktopMedium); } @@ -74,7 +334,12 @@ const $SpotLayout = styled.article<{ `, })[tradeLayout]} - // Rules + ${({ isHorizontalOpen }) => + !isHorizontalOpen && + css` + --horizontalPanel-height: auto !important; + `} + width: 0; min-width: 100%; height: 0; @@ -102,3 +367,19 @@ const $SpotLayout = styled.article<{ const $GridSection = styled.section<{ gridArea: string }>` grid-area: ${({ gridArea }) => gridArea}; `; + +const $SideGridSection = styled($GridSection)` + ${layoutMixins.withInnerHorizontalBorders} +`; + +const $Output = styled(Output)` + line-height: 1; +`; + +const $CoverUpTradingView = styled.div` + width: 100%; + height: 100%; + position: absolute; + z-index: 2; + background: rgba(0, 0, 0, 0.2); +`; diff --git a/src/pages/spot/SpotFormInput.tsx b/src/pages/spot/SpotFormInput.tsx new file mode 100644 index 0000000000..f260629dd6 --- /dev/null +++ b/src/pages/spot/SpotFormInput.tsx @@ -0,0 +1,208 @@ +import { forwardRef, useCallback, useId, useImperativeHandle, useMemo, useRef } from 'react'; + +import { SpotBuyInputType, SpotSellInputType, SpotSide } from '@/bonsai/forms/spot'; +import styled from 'styled-components'; + +import { AlertType } from '@/constants/alerts'; +import { ButtonAction, ButtonSize, ButtonStyle } from '@/constants/buttons'; +import { PERCENT_DECIMALS, TOKEN_DECIMALS, USD_DECIMALS } from '@/constants/numbers'; + +import { AlertMessage } from '@/components/AlertMessage'; +import { Icon, IconName } from '@/components/Icon'; +import { IconButton } from '@/components/IconButton'; +import { Input, InputProps, InputType } from '@/components/Input'; +import { Output, OutputProps, OutputType } from '@/components/Output'; +import { TabGroup, TabOption } from '@/components/TabGroup'; + +const INPUT_CONFIG_MAP = { + [SpotSide.BUY]: { + [SpotBuyInputType.SOL]: { + decimals: TOKEN_DECIMALS, + type: InputType.Number, + }, + [SpotBuyInputType.USD]: { + decimals: USD_DECIMALS, + type: InputType.Currency, + }, + }, + [SpotSide.SELL]: { + [SpotSellInputType.PERCENT]: { + decimals: PERCENT_DECIMALS, + type: InputType.Percent, + }, + [SpotSellInputType.USD]: { + decimals: USD_DECIMALS, + type: InputType.Currency, + }, + }, +} as const; + +const TAB_OPTIONS_MAP: Record[]> = { + [SpotSide.SELL]: [ + { + label: , + value: SpotSellInputType.PERCENT, + }, + { + label: , + value: SpotSellInputType.USD, + }, + ], + [SpotSide.BUY]: [ + { + label: , + value: SpotBuyInputType.USD, + }, + { + label: , + value: SpotBuyInputType.SOL, + }, + ], +}; + +export type SpotFormInputProps = { + className?: string; + tokenSymbol: string; + tokenAmount: number; + balances: { + sol: number; + token: number; + usd: number; + }; + side: SpotSide; + inputType: SpotBuyInputType | SpotSellInputType; + onInputTypeChange: (side: SpotSide, inputType: SpotBuyInputType | SpotSellInputType) => void; + validationConfig?: { + type: AlertType; + message: string; + }; +} & InputProps; + +// TODO: spot localization + +export const SpotFormInput = forwardRef( + ( + { + className, + validationConfig, + inputType, + side, + balances, + tokenSymbol, + tokenAmount, + onInputTypeChange, + ...inputProps + }, + ref + ) => { + const id = useId(); + const internalRef = useRef(null); + + useImperativeHandle(ref, () => internalRef.current!); + + const balanceOutputProps: OutputProps = useMemo(() => { + if (side === SpotSide.BUY) { + if (inputType === SpotBuyInputType.SOL) { + return { + value: balances.sol, + type: OutputType.Asset, + slotRight: ' SOL', + }; + } + + return { + value: balances.usd, + type: OutputType.CompactNumber, + slotRight: ' USD', + }; + } + + return { + value: balances.token, + type: OutputType.Asset, + slotRight: ` ${tokenSymbol}`, + }; + }, [balances.sol, balances.token, balances.usd, inputType, side, tokenSymbol]); + + const handleContainerClick = useCallback(() => { + if (!internalRef.current) return; + internalRef.current.focus(); + }, []); + + const handleInputTypeChange = useCallback( + (value: SpotBuyInputType | SpotSellInputType) => { + onInputTypeChange(side, value); + }, + [onInputTypeChange, side] + ); + + const inputConfig = + side === SpotSide.BUY + ? INPUT_CONFIG_MAP[SpotSide.BUY][inputType as SpotBuyInputType] + : INPUT_CONFIG_MAP[SpotSide.SELL][inputType as SpotSellInputType]; + + const tabGroupOptions = TAB_OPTIONS_MAP[side]; + + const mergedProps = { + ...inputProps, + ...inputConfig, + } as InputProps; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+ + + {side === SpotSide.BUY && ( + + )} +
+
+
+
+ <$Input ref={internalRef} {...mergedProps} id={id} /> + +
+
+ <$TabGroup + onTabChange={handleInputTypeChange} + options={tabGroupOptions} + value={inputType} + /> +
+
+
+ {validationConfig && ( + {validationConfig.message} + )} +
+ ); + } +); + +const $Input = styled(Input)` + --input-font: var(--font-medium-medium); +` as typeof Input; + +const $TabGroup = styled(TabGroup)` + --tab-group-height: 2rem; +` as typeof TabGroup; diff --git a/src/pages/spot/SpotHeader.tsx b/src/pages/spot/SpotHeader.tsx new file mode 100644 index 0000000000..bd1a3197ef --- /dev/null +++ b/src/pages/spot/SpotHeader.tsx @@ -0,0 +1,55 @@ +import styled from 'styled-components'; + +import { layoutMixins } from '@/styles/layoutMixins'; + +import { VerticalSeparator } from '@/components/Separator'; + +import { SpotMarketStatsRow } from './SpotMarketStatsRow'; +import { SpotMarketsDropdown } from './SpotMarketsDropdown'; +import { SpotHeaderToken } from './types'; + +export type SpotHeaderProps = { + currentToken?: SpotHeaderToken | null; + searchResults?: SpotHeaderToken[]; + isSearchLoading?: boolean; + isTokenLoading?: boolean; + onTokenSelect: (token: SpotHeaderToken) => void; + onSearchTextChange?: (value: string) => void; + className?: string; +}; + +export const SpotHeader = ({ + currentToken, + searchResults = [], + isSearchLoading, + isTokenLoading, + onTokenSelect, + onSearchTextChange, + className, +}: SpotHeaderProps) => { + return ( + <$Container className={className}> + + + + + ); +}; + +const $Container = styled.div` + ${layoutMixins.container} + ${layoutMixins.scrollAreaFadeEnd} + + display: grid; + grid-template: var(--market-info-row-height) / auto; + grid-auto-flow: column; + justify-content: start; + align-items: stretch; +`; diff --git a/src/pages/spot/SpotHoldingsTable.tsx b/src/pages/spot/SpotHoldingsTable.tsx new file mode 100644 index 0000000000..bf45b96944 --- /dev/null +++ b/src/pages/spot/SpotHoldingsTable.tsx @@ -0,0 +1,234 @@ +import { useMemo } from 'react'; + +import { ColumnSize } from '@react-types/table'; +import styled from 'styled-components'; + +import { ButtonAction, ButtonSize, ButtonStyle } from '@/constants/buttons'; +import { SMALL_USD_DECIMALS } from '@/constants/numbers'; + +import { defaultTableMixins } from '@/styles/tableMixins'; + +import { AssetIcon } from '@/components/AssetIcon'; +import { Button } from '@/components/Button'; +import { Icon, IconName } from '@/components/Icon'; +import { Output, OutputType, ShowSign } from '@/components/Output'; +import { ColumnDef, Table } from '@/components/Table'; +import { TableCell } from '@/components/Table/TableCell'; + +export interface SpotPositionItem { + tokenAddress: string; + tokenName: string; + tokenSymbol: string; + tokenImage?: string; + holdingsAmount?: number; + holdingsUsd?: number; + boughtAmount?: number; + boughtUsd?: number; + soldAmount?: number; + soldUsd?: number; + pnlUsd?: number; + avgEntryUsd?: number; +} + +export enum SpotHoldingsTableColumnKey { + Token = 'Token', + Holdings = 'Holdings', + Bought = 'Bought', + Sold = 'Sold', + PnL = 'PnL', + TpSl = 'TpSl', + Actions = 'Actions', + AvgEntry = 'AvgEntry', +} + +// TODO: spot localization + +const getColumnDef = ({ + key, + width, + onSellAction, +}: { + key: SpotHoldingsTableColumnKey; + width?: ColumnSize; + onSellAction?: (row: SpotPositionItem) => void; +}): ColumnDef => ({ + width, + ...( + { + [SpotHoldingsTableColumnKey.Token]: { + columnKey: 'token', + label: 'Token', + getCellValue: (row) => row.tokenSymbol, + renderCell: ({ tokenImage, tokenSymbol }) => ( + + } + > + {tokenSymbol} + + ), + }, + [SpotHoldingsTableColumnKey.Holdings]: { + columnKey: 'holdingsUsd', + label: 'Holdings', + getCellValue: (row) => row.holdingsUsd ?? 0, + renderCell: ({ holdingsUsd, holdingsAmount, tokenName }) => ( + + + {holdingsAmount != null && ( + + + {tokenName} + + )} + + ), + }, + [SpotHoldingsTableColumnKey.Bought]: { + columnKey: 'boughtUsd', + label: 'Bought', + getCellValue: (row) => row.boughtUsd ?? 0, + renderCell: ({ boughtUsd, boughtAmount, tokenName }) => ( + + + {boughtAmount != null && ( + + + {tokenName} + + )} + + ), + }, + [SpotHoldingsTableColumnKey.Sold]: { + columnKey: 'soldUsd', + label: 'Sold', + getCellValue: (row) => row.soldUsd ?? 0, + renderCell: ({ soldUsd, soldAmount, tokenName }) => ( + + + {soldAmount != null && ( + + + {tokenName} + + )} + + ), + }, + [SpotHoldingsTableColumnKey.AvgEntry]: { + columnKey: 'avgEntry', + label: 'Avg Entry', + getCellValue: (row) => row.avgEntryUsd ?? 0, + renderCell: ({ avgEntryUsd }) => ( + + + + ), + }, + [SpotHoldingsTableColumnKey.PnL]: { + columnKey: 'pnlUsd', + label: 'PNL', + getCellValue: (row) => row.pnlUsd ?? 0, + renderCell: ({ pnlUsd }) => ( + + + + ), + }, + [SpotHoldingsTableColumnKey.TpSl]: { + columnKey: 'tpsl', + label: 'TP/SL', + allowsSorting: false, + renderCell: () => -, + }, + [SpotHoldingsTableColumnKey.Actions]: { + columnKey: 'actions', + label: 'Actions', + allowsSorting: false, + isActionable: true, + renderCell: (row) => ( + + + + ), + }, + } satisfies Record> + )[key], +}); + +export type SpotHoldingsTableProps = { + data: SpotPositionItem[]; + columnKeys?: SpotHoldingsTableColumnKey[]; + columnWidths?: Partial>; + onRowAction?: (row: SpotPositionItem) => void; + onSellAction?: (row: SpotPositionItem) => void; +}; + +export const SpotHoldingsTable = ({ + data, + columnKeys = [ + SpotHoldingsTableColumnKey.Token, + SpotHoldingsTableColumnKey.Holdings, + SpotHoldingsTableColumnKey.Bought, + SpotHoldingsTableColumnKey.Sold, + SpotHoldingsTableColumnKey.AvgEntry, + SpotHoldingsTableColumnKey.PnL, + SpotHoldingsTableColumnKey.Actions, + ], + columnWidths, + onRowAction, + onSellAction, +}: SpotHoldingsTableProps) => { + const columns = useMemo( + () => columnKeys.map((key) => getColumnDef({ key, width: columnWidths?.[key], onSellAction })), + [columnKeys, columnWidths, onSellAction] + ); + + return ( + <$Table + tableId="spot-holdings" + data={data} + getRowKey={(row) => row.tokenAddress} + onRowAction={(_, row) => { + onRowAction?.(row); + }} + columns={columns} + paginationBehavior="showAll" + withInnerBorders + withScrollSnapColumns + withScrollSnapRows + slotEmpty={ + <> + +

Holdings are empty...

+ + } + /> + ); +}; + +const $Table = styled(Table)` + ${defaultTableMixins} +` as typeof Table; diff --git a/src/pages/spot/SpotHorizontalPanel.tsx b/src/pages/spot/SpotHorizontalPanel.tsx new file mode 100644 index 0000000000..8bc680be86 --- /dev/null +++ b/src/pages/spot/SpotHorizontalPanel.tsx @@ -0,0 +1,103 @@ +import { useMemo, useState } from 'react'; + +import styled from 'styled-components'; + +import { CollapsibleTabs } from '@/components/CollapsibleTabs'; +import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; + +import { + SpotHoldingsTable, + SpotHoldingsTableProps, + type SpotPositionItem, +} from './SpotHoldingsTable'; +import { SpotTradesTable, type SpotTradeItem } from './SpotTradesTable'; + +type SpotHorizontalPanelProps = { + holdings?: SpotPositionItem[]; + trades?: SpotTradeItem[]; + isHoldingsLoading?: boolean; + isTradesLoading?: boolean; + isOpen?: boolean; + setIsOpen?: (isOpen: boolean) => void; + onRowAction?: SpotHoldingsTableProps['onRowAction']; + onSellAction?: SpotHoldingsTableProps['onSellAction']; + handleStartResize?: (e: React.MouseEvent) => void; +}; + +// TODO: spot localization + +enum PanelTabs { + Holdings = 'Holdings', + Trades = 'Trades', +} + +export const SpotHorizontalPanel = ({ + holdings = [], + trades = [], + isHoldingsLoading = false, + isTradesLoading = false, + isOpen = true, + setIsOpen, + onRowAction, + onSellAction, + handleStartResize, +}: SpotHorizontalPanelProps) => { + const [tab, setTab] = useState(PanelTabs.Holdings); + + const tabItems = useMemo( + () => [ + { + value: PanelTabs.Holdings, + label: 'Holdings', + content: isHoldingsLoading ? ( + + ) : ( + + ), + }, + { + value: PanelTabs.Trades, + label: 'Trades', + content: isTradesLoading ? : , + }, + ], + [isHoldingsLoading, holdings, onRowAction, onSellAction, isTradesLoading, trades] + ); + + return ( + <> + <$DragHandle onMouseDown={handleStartResize} /> + <$CollapsibleTabs + defaultTab={PanelTabs.Holdings} + tab={tab} + setTab={setTab} + defaultOpen={isOpen} + onOpenChange={setIsOpen} + dividerStyle="underline" + tabItems={tabItems} + /> + + ); +}; + +const $CollapsibleTabs = styled(CollapsibleTabs)` + header { + background-color: var(--color-layer-2); + } + + --trigger-active-underline-backgroundColor: var(--color-layer-2); +` as typeof CollapsibleTabs; + +const $DragHandle = styled.div` + width: 100%; + height: 0.5rem; + cursor: ns-resize; + position: absolute; + top: 0; + left: 0; + z-index: 2; +`; diff --git a/src/pages/spot/SpotMarketStatsRow.tsx b/src/pages/spot/SpotMarketStatsRow.tsx new file mode 100644 index 0000000000..774d097070 --- /dev/null +++ b/src/pages/spot/SpotMarketStatsRow.tsx @@ -0,0 +1,109 @@ +import styled from 'styled-components'; + +import { SMALL_USD_DECIMALS } from '@/constants/numbers'; + +import { layoutMixins } from '@/styles/layoutMixins'; + +import { Details, type DetailsItem } from '@/components/Details'; +import { Output, OutputType, ShowSign } from '@/components/Output'; + +import { SpotHeaderToken } from './types'; + +type SpotMarketStatsRowProps = { + stats?: SpotHeaderToken | null; + isLoading?: boolean; +}; + +// TODO: spot localization + +export const SpotMarketStatsRow = ({ stats, isLoading = false }: SpotMarketStatsRowProps) => { + const items: DetailsItem[] = [ + { + key: 'market-cap', + label: 'Market Cap', + value: , + }, + { + key: 'price', + label: 'Price', + value: ( + + ), + }, + { + key: 'fdv', + label: 'FDV', + value: , + }, + { + key: 'liquidity', + label: 'Liquidity', + value: , + }, + { + key: 'supply', + label: 'Circulating/Total Supply', + value: ( + + / + + + ), + }, + { + key: 'change', + label: '% Change 24h', + value: ( + + ), + }, + { + key: 'volume', + label: 'Volume 24h', + value: , + }, + { + key: 'buys', + label: 'Buys 24h', + value: ( + + ), + }, + { + key: 'sells', + label: 'Sells 24h', + value: ( + + ), + }, + ]; + + return <$Details items={items} layout="rowColumns" withSeparators isLoading={isLoading} />; +}; + +const $Details = styled(Details)` + ${layoutMixins.scrollArea} + ${layoutMixins.row} + isolation: isolate; + font: var(--font-mini-book); +` as typeof Details; diff --git a/src/pages/spot/SpotMarketsDropdown.tsx b/src/pages/spot/SpotMarketsDropdown.tsx new file mode 100644 index 0000000000..1be310e3eb --- /dev/null +++ b/src/pages/spot/SpotMarketsDropdown.tsx @@ -0,0 +1,290 @@ +import { useMemo, useState } from 'react'; + +import styled from 'styled-components'; + +import { SMALL_USD_DECIMALS } from '@/constants/numbers'; + +import { layoutMixins } from '@/styles/layoutMixins'; +import { popoverMixins } from '@/styles/popoverMixins'; + +import { AssetIcon } from '@/components/AssetIcon'; +import { DropdownIcon } from '@/components/DropdownIcon'; +import { Icon, IconName } from '@/components/Icon'; +import { LoadingDots } from '@/components/Loading/LoadingDots'; +import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; +import { Output, OutputType, ShowSign } from '@/components/Output'; +import { Popover, TriggerType } from '@/components/Popover'; +import { SearchInput } from '@/components/SearchInput'; +import { ColumnDef, Table } from '@/components/Table'; +import { TableCell } from '@/components/Table/TableCell'; +import { Toolbar } from '@/components/Toolbar'; +import { FavoriteButton } from '@/views/tables/MarketsTable/FavoriteButton'; + +import { useAppSelector } from '@/state/appTypes'; +import { getSpotFavorites } from '@/state/appUiConfigsSelectors'; + +import { truncateAddress } from '@/lib/wallet'; + +import { SpotHeaderToken } from './types'; + +type SpotMarketsDropdownProps = { + current?: SpotHeaderToken | null; + searchResults?: SpotHeaderToken[]; + isSearchLoading?: boolean; + isTokenLoading?: boolean; + onSelect: (token: SpotHeaderToken) => void; + onSearchTextChange?: (value: string) => void; + className?: string; +}; + +// TODO: spot localization + +export const SpotMarketsDropdown = ({ + current, + searchResults = [], + isSearchLoading, + isTokenLoading, + onSelect, + onSearchTextChange, + className, +}: SpotMarketsDropdownProps) => { + const [isOpen, setIsOpen] = useState(false); + const favoritedTokenAddresses = useAppSelector(getSpotFavorites); + const favoritedSet = useMemo(() => new Set(favoritedTokenAddresses), [favoritedTokenAddresses]); + + const columns = useMemo( + () => + [ + { + columnKey: 'market', + getCellValue: (row) => row.name, + label: 'Name', + renderCell: ({ symbol, logoUrl, tokenAddress }) => ( +
+ + <$AssetIcon logoUrl={logoUrl} symbol={symbol} /> +
+

{symbol}

+ + {truncateAddress(tokenAddress, '')} + +
+
+ ), + }, + { + columnKey: 'volume24h', + getCellValue: (row) => row.volume24hUsd ?? 0, + label: 'Volume', + renderCell: ({ volume24hUsd }) => ( + + ), + }, + { + columnKey: 'price', + getCellValue: (row: SpotHeaderToken) => row.priceUsd ?? 0, + label: 'Price', + renderCell: ({ priceUsd }) => ( + + ), + }, + { + columnKey: 'marketCap', + getCellValue: (row) => row.marketCapUsd ?? 0, + label: 'Market Cap', + renderCell: ({ marketCapUsd, change24hPercent }) => ( + + + + + ), + }, + ] satisfies ColumnDef[], + [] + ); + + return ( + <$Popover + open={isOpen} + onOpenChange={setIsOpen} + noBlur + sideOffset={1} + className={className} + slotTrigger={ + <$TriggerContainer $isOpen={isOpen}> +
+ <$AssetIconWithStar> + {current?.tokenAddress && favoritedSet.has(current.tokenAddress) && ( + <$FavoriteStatus iconName={IconName.Star} /> + )} + <$AssetIcon + logoUrl={current?.logoUrl} + symbol={current?.symbol} + isLoading={isTokenLoading} + /> + + {isTokenLoading ? ( + + ) : ( +

{current?.symbol}

+ )} +
+

+ +

+ + } + triggerType={TriggerType.MarketDropdown} + > +
+ <$Toolbar> + <$SearchInput placeholder="Search markets" onTextChange={onSearchTextChange} /> + + <$ScrollArea> + {isSearchLoading ? ( + + ) : ( + <$Table + withOuterBorder + withInnerBorders + data={searchResults} + tableId="spot-markets-dropdown" + getRowKey={(row) => row.tokenAddress} + onRowAction={(_, row) => { + onSelect(row); + setIsOpen(false); + }} + label="Spot" + columns={columns} + paginationBehavior="paginate" + initialPageSize={20} + shouldResetOnTotalRowsChange + getIsRowPinned={(row) => favoritedSet.has(row.tokenAddress)} + defaultSortDescriptor={{ + column: 'volume24h', + direction: 'descending', + }} + slotEmpty={ + <> + +

Failed to find a matching token...

+ + } + /> + )} + +
+ + ); +}; + +const $Toolbar = styled(Toolbar)` + gap: 0.5rem; + border-bottom: solid var(--border-width) var(--color-border); + padding: 1rem 1rem 0.5rem; +`; + +const $SearchInput = styled(SearchInput)` + min-width: 12rem; + flex-grow: 1; +` as typeof SearchInput; + +const $TriggerContainer = styled.div<{ $isOpen: boolean }>` + position: relative; + + ${layoutMixins.spacedRow} + padding: 0 1.25rem; + + transition: width 0.1s; + gap: 1rem; +`; + +const $Popover = styled(Popover)` + ${popoverMixins.popover} + --popover-item-height: 2.75rem; + + --popover-backgroundColor: var(--color-layer-2); + display: flex; + flex-direction: column; + + height: calc( + 100vh - var(--page-header-height) - var(--market-info-row-height) - var( + --page-footer-height + ) - var(--restriction-warning-currentHeight) + ); + + width: var(--marketsDropdown-openWidth); + + max-width: 100vw; + + box-shadow: 0 0 0 1px var(--color-border); + border-radius: 0; + + &:focus-visible { + outline: none; + } +`; + +const $ScrollArea = styled.div` + overflow: scroll; + position: relative; + height: 100%; +`; + +const $Table = styled(Table)` + --tableCell-padding: 0.5rem 1rem; + --table-header-height: 2.25rem; + + thead { + --stickyArea-totalInsetTop: 0px; + --stickyArea-totalInsetBottom: 0px; + background-color: var(--color-layer-2); + + tr { + height: var(--stickyArea-topHeight); + } + } + + tfoot { + --stickyArea-totalInsetTop: 0px; + --stickyArea-totalInsetBottom: 3px; + background-color: var(--color-layer-2); + + tr { + height: var(--stickyArea-bottomHeight); + } + } + + tr { + height: var(--popover-item-height); + } +` as typeof Table; + +const $AssetIcon = styled(AssetIcon)` + --asset-icon-size: 1.5em; +`; + +const $FavoriteStatus = styled(Icon)` + --icon-size: 0.75em; + --icon-color: ${({ theme }) => theme.profileYellow}; + color: var(--icon-color); + fill: var(--icon-color); + z-index: 1; +`; + +const $AssetIconWithStar = styled.div` + ${layoutMixins.stack} + + ${$AssetIcon} { + margin: 0.2rem; + } +`; diff --git a/src/pages/spot/SpotTabs.tsx b/src/pages/spot/SpotTabs.tsx new file mode 100644 index 0000000000..391210f914 --- /dev/null +++ b/src/pages/spot/SpotTabs.tsx @@ -0,0 +1,151 @@ +import React, { ReactNode } from 'react'; + +import { Content, List, Root, Trigger } from '@radix-ui/react-tabs'; +import styled, { css } from 'styled-components'; + +import { layoutMixins } from '@/styles/layoutMixins'; + +export enum SpotTabVariant { + Buy = 'Buy', + Sell = 'Sell', + Default = 'Default', +} + +export type SpotTabItem = { + value: string; + label: ReactNode; + content?: ReactNode; + disabled?: boolean; + variant?: SpotTabVariant; +}; + +export type SpotTabsProps = { + items: SpotTabItem[]; + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + className?: string; + sharedContent?: React.ReactNode; + disabled?: boolean; + hideTabs?: boolean; +}; + +export const SpotTabs = ({ + items, + value, + defaultValue, + onValueChange, + className, + sharedContent, + disabled, + hideTabs = false, +}: SpotTabsProps) => { + const fallbackValue = items.find((i) => !i.disabled)?.value; + + return ( + <$Root + value={value} + defaultValue={defaultValue ?? fallbackValue} + onValueChange={onValueChange} + className={className} + > + {!hideTabs && ( + <$List> + {items.map((item) => ( + <$Trigger + key={item.value} + value={item.value} + disabled={item.disabled ?? disabled} + $variant={item.variant ?? SpotTabVariant.Default} + > + {item.label} + + ))} + + )} + {sharedContent ?? + items.map((item) => ( + <$Content key={item.value} value={item.value}> + {item.content} + + ))} + + ); +}; + +const $Root = styled(Root)` + --tab-border-radius: 10px; + gap: 1rem; + + ${layoutMixins.contentContainer} + ${layoutMixins.scrollArea} +`; + +const $List = styled(List)` + display: flex; + flex-direction: row; + padding: 0.125rem; + background-color: var(--color-layer-1); + border-radius: var(--tab-border-radius); + gap: 0.125rem; +`; + +const spotTabVariants: Record> = { + [SpotTabVariant.Buy]: css` + --tab-textColor: var(--color-green); + --tab-backgroundColor: var(--color-green-faded); + `, + + [SpotTabVariant.Sell]: css` + --tab-textColor: var(--color-red); + --tab-backgroundColor: var(--color-red-faded); + `, + + [SpotTabVariant.Default]: css` + --tab-textColor: var(--color-text-2); + --tab-backgroundColor: var(--color-layer-4); + `, +}; + +const $Trigger = styled(Trigger)<{ + $variant: SpotTabVariant; +}>` + --tab-textColor: var(--color-text-0); + --tab-backgroundColor: var(--color-layer-1); + --tab-hover-backgroundColor: var(--color-layer-4); + --tab-hover-filter: brightness(var(--hover-filter-variant)); + + ${layoutMixins.textTruncate} + + &[data-state='active'] { + ${({ $variant }) => spotTabVariants[$variant]} + } + + &:disabled { + opacity: 0.5; + } + + &:hover:not(:disabled) { + filter: var(--tab-hover-filter); + } + + background-color: var(--tab-backgroundColor); + color: var(--tab-textColor); + cursor: pointer; + font-size: var(--fontSize-base); + font-weight: 500; + flex: 1; + height: 2.5rem; + border-radius: calc(var(--tab-border-radius) - 0.125rem); + padding: 0 1rem; + text-align: center; +`; + +const $Content = styled(Content)` + ${layoutMixins.flexColumn} + flex: 1; + + &[data-state='inactive'] { + display: none; + } +`; diff --git a/src/pages/spot/SpotTokenInfo.tsx b/src/pages/spot/SpotTokenInfo.tsx new file mode 100644 index 0000000000..541f53d5c6 --- /dev/null +++ b/src/pages/spot/SpotTokenInfo.tsx @@ -0,0 +1,110 @@ +import { type ReactNode } from 'react'; + +import { + ButtonAction, + ButtonShape, + ButtonSize, + ButtonStyle, + ButtonType, +} from '@/constants/buttons'; + +import { CopyButton } from '@/components/CopyButton'; +import { Icon, IconName } from '@/components/Icon'; +import { IconButton } from '@/components/IconButton'; +import { InfoGrid } from '@/components/InfoGrid'; +import { Output, OutputType } from '@/components/Output'; + +import { truncateAddress } from '@/lib/wallet'; + +export type TokenInfoLink = { + icon: IconName; + url: string; + title?: string; +}; + +export type TokenInfoItem = { + key: string; + label: string; + value: ReactNode; + iconName?: IconName; +}; + +type SpotTokenInfoProps = { + links: TokenInfoLink[]; + items: TokenInfoItem[]; + contractAddress: string; + createdAt?: number | string | Date; + className?: string; + isLoading?: boolean; +}; + +// TODO: spot localization + +export const SpotTokenInfo = ({ + links, + items, + contractAddress, + createdAt, + className, + isLoading = false, +}: SpotTokenInfoProps) => { + return ( +
+
+
Token Info
+
+ {(isLoading ? [null] : links).map((l) => ( + + ))} +
+
+ + ({ + key: item.key, + label: ( + + {item.iconName && } + {item.label} + + ), + value: item.value, + }))} + /> + +
+ + {truncateAddress(contractAddress, '')} + + {(isLoading || !!createdAt) && ( +
+ + +
+ )} +
+
+ ); +}; diff --git a/src/pages/spot/SpotTradeForm.tsx b/src/pages/spot/SpotTradeForm.tsx new file mode 100644 index 0000000000..688a1ccf70 --- /dev/null +++ b/src/pages/spot/SpotTradeForm.tsx @@ -0,0 +1,183 @@ +import { useCallback, useMemo, useRef } from 'react'; + +import { SpotBuyInputType, SpotSellInputType, SpotSide } from '@/bonsai/forms/spot'; +import { BonsaiCore } from '@/bonsai/ontology'; + +import { ButtonAction, ButtonState } from '@/constants/buttons'; +import { STRING_KEYS } from '@/constants/localization'; + +import { useSpotForm } from '@/hooks/useSpotForm'; +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { Button } from '@/components/Button'; +import { Icon, IconName } from '@/components/Icon'; +import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; +import { ValidationAlertMessage } from '@/components/ValidationAlert'; + +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { setSpotQuickOptions } from '@/state/appUiConfigs'; +import { getSpotQuickOptions } from '@/state/appUiConfigsSelectors'; + +import { mapIfPresent } from '@/lib/do'; + +import { QuickButtonProps, QuickButtons } from './QuickButtons'; +import { SpotFormInput } from './SpotFormInput'; +import { SpotTabs, SpotTabVariant } from './SpotTabs'; + +export const SpotTradeForm = () => { + const stringGetter = useStringGetter(); + const dispatch = useAppDispatch(); + const form = useSpotForm(); + const tokenMetadata = useAppSelector(BonsaiCore.spot.tokenMetadata.data); + const quickOptionsState = useAppSelector(getSpotQuickOptions); + + const inputRef = useRef(null); + + const quickOptions = useMemo(() => { + if (form.state.side === SpotSide.BUY) { + return quickOptionsState[SpotSide.BUY][form.state.buyInputType]; + } + return quickOptionsState[SpotSide.SELL][form.state.sellInputType]; + }, [form.state.side, form.state.buyInputType, form.state.sellInputType, quickOptionsState]); + + const validationConfig = useMemo(() => { + if (form.state.side === SpotSide.BUY) { + return { min: 0, decimalScale: 2 }; + } + return form.state.sellInputType === SpotSellInputType.PERCENT + ? { min: 0, max: 100, decimalScale: 2 } + : { min: 0, decimalScale: 2 }; + }, [form.state.side, form.state.sellInputType]); + + const currencyIndicator = useMemo((): Pick< + QuickButtonProps, + 'prefix' | 'suffix' | 'slotRight' + > => { + const { side, buyInputType, sellInputType } = form.state; + + if (side === SpotSide.BUY && buyInputType === SpotBuyInputType.USD) { + return { prefix: '$' }; + } + + if (side === SpotSide.BUY && buyInputType === SpotBuyInputType.SOL) { + return { slotRight: }; + } + + if (side === SpotSide.SELL && sellInputType === SpotSellInputType.USD) { + return { prefix: '$' }; + } + + if (side === SpotSide.SELL && sellInputType === SpotSellInputType.PERCENT) { + return { suffix: '%' }; + } + + return {}; + }, [form.state]); + + const handleQuickOptionsChange = useCallback( + (newOptions: string[]) => { + dispatch( + setSpotQuickOptions({ + side: form.state.side, + inputType: + form.state.side === SpotSide.BUY ? form.state.buyInputType : form.state.sellInputType, + options: newOptions, + }) + ); + }, + [dispatch, form.state.buyInputType, form.state.sellInputType, form.state.side] + ); + + return ( + { + form.actions.setSide(v as SpotSide); + }} + sharedContent={ +
+ {!form.inputData.isReady ? ( + + ) : ( + <> + + form.actions.setSize(formattedValue) + } + balances={{ + sol: form.inputData.userSolBalance ?? 0, + token: form.inputData.userTokenBalance ?? 0, + usd: + mapIfPresent( + form.inputData.userSolBalance, + form.inputData.solPriceUsd, + (solBalance, solPrice) => solBalance * solPrice + ) ?? 0, + }} + inputType={ + form.state.side === SpotSide.BUY + ? form.state.buyInputType + : form.state.sellInputType + } + onInputTypeChange={form.handleInputTypeChange} + side={form.state.side} + tokenAmount={form.summary.amounts?.token ?? 0} + tokenSymbol={tokenMetadata?.symbol ?? ''} + /> + form.actions.setSize(val)} + onOptionsEdit={handleQuickOptionsChange} + currentValue={form.state.size} + disabled={form.isPending} + validation={validationConfig} + {...currencyIndicator} + /> + {form.primaryAlert != null && + (form.primaryAlert.resources.text?.stringKey != null || + form.primaryAlert.resources.text?.fallback != null) && ( + + )} + + + )} +
+ } + items={[ + { + label: stringGetter({ key: STRING_KEYS.BUY }), + value: SpotSide.BUY, + variant: SpotTabVariant.Buy, + }, + { + label: stringGetter({ key: STRING_KEYS.SELL }), + value: SpotSide.SELL, + variant: SpotTabVariant.Sell, + }, + ]} + /> + ); +}; diff --git a/src/pages/spot/SpotTradesTable.tsx b/src/pages/spot/SpotTradesTable.tsx new file mode 100644 index 0000000000..c46db907a4 --- /dev/null +++ b/src/pages/spot/SpotTradesTable.tsx @@ -0,0 +1,200 @@ +import { useMemo } from 'react'; + +import { OrderSide } from '@dydxprotocol/v4-client-js'; +import { ColumnSize } from '@react-types/table'; +import styled from 'styled-components'; + +import { ButtonStyle, ButtonType } from '@/constants/buttons'; + +import { defaultTableMixins } from '@/styles/tableMixins'; + +import { AssetIcon } from '@/components/AssetIcon'; +import { Icon, IconName } from '@/components/Icon'; +import { IconButton } from '@/components/IconButton'; +import { OrderSideTag } from '@/components/OrderSideTag'; +import { Output, OutputType } from '@/components/Output'; +import { ColumnDef, Table } from '@/components/Table'; +import { TableCell } from '@/components/Table/TableCell'; +import { TagSize } from '@/components/Tag'; + +export interface SpotTradeItem { + id: string; + side: 'buy' | 'sell'; + tokenAmount: number; + usdValue: number; + txHash: string; + createdAt: string; + tokenSymbol?: string; + tokenImage?: string; + marketCapUsd?: number; +} + +export enum SpotTradesTableColumnKey { + Time = 'Time', + Token = 'Token', + Type = 'Type', + MarketCap = 'MarketCap', + Amount = 'Amount', + UsdAmount = 'UsdAmount', + ViewTxn = 'ViewTxn', +} + +// TODO: spot localization + +const getColumnDef = ({ + key, + width, +}: { + key: SpotTradesTableColumnKey; + width?: ColumnSize; +}): ColumnDef => ({ + width, + ...( + { + [SpotTradesTableColumnKey.Time]: { + columnKey: 'time', + label: 'Time', + getCellValue: (row) => row.createdAt, + renderCell: ({ createdAt }) => ( + + + + ), + }, + [SpotTradesTableColumnKey.Token]: { + columnKey: 'token', + label: 'Token', + getCellValue: (row) => row.tokenSymbol, + renderCell: ({ tokenSymbol, tokenImage }) => ( + + } + > + {tokenSymbol ?? 'Unknown'} + + ), + }, + [SpotTradesTableColumnKey.Type]: { + columnKey: 'side', + label: 'Type', + getCellValue: (row) => row.side, + renderCell: ({ side }) => ( + + + + ), + }, + [SpotTradesTableColumnKey.MarketCap]: { + columnKey: 'marketCapUsd', + label: 'Market Cap', + getCellValue: (row) => row.marketCapUsd ?? 0, + renderCell: ({ marketCapUsd }) => ( + + + + ), + }, + [SpotTradesTableColumnKey.Amount]: { + columnKey: 'tokenAmount', + label: 'Amount', + getCellValue: (row) => row.tokenAmount, + renderCell: ({ tokenAmount, tokenSymbol }) => ( + + + + ), + }, + [SpotTradesTableColumnKey.UsdAmount]: { + columnKey: 'usdValue', + label: 'USD Amount', + getCellValue: (row) => row.usdValue, + renderCell: ({ usdValue }) => ( + + + + ), + }, + [SpotTradesTableColumnKey.ViewTxn]: { + columnKey: 'txHash', + label: 'View Txn', + allowsSorting: false, + isActionable: true, + renderCell: ({ txHash }) => ( + + + + ), + }, + } satisfies Record> + )[key], +}); + +export type SpotTradesTableProps = { + data: SpotTradeItem[]; + columnKeys?: SpotTradesTableColumnKey[]; + columnWidths?: Partial>; +}; + +export const SpotTradesTable = ({ + data, + columnKeys = [ + SpotTradesTableColumnKey.Time, + SpotTradesTableColumnKey.Token, + SpotTradesTableColumnKey.Type, + SpotTradesTableColumnKey.MarketCap, + SpotTradesTableColumnKey.Amount, + SpotTradesTableColumnKey.UsdAmount, + SpotTradesTableColumnKey.ViewTxn, + ], + columnWidths, +}: SpotTradesTableProps) => { + const columns = useMemo( + () => columnKeys.map((key) => getColumnDef({ key, width: columnWidths?.[key] })), + [columnKeys, columnWidths] + ); + + return ( + <$Table + defaultSortDescriptor={{ + column: 'time', + direction: 'descending', + }} + tableId="spot-trades" + data={data} + getRowKey={(row) => row.id} + columns={columns} + initialPageSize={20} + paginationBehavior="paginate" + withInnerBorders + withScrollSnapColumns + withScrollSnapRows + slotEmpty={ + <> + +

No trades yet...

+ + } + /> + ); +}; + +const $Table = styled(Table)` + ${defaultTableMixins} +` as typeof Table; diff --git a/src/pages/spot/StackedIcon.tsx b/src/pages/spot/StackedIcon.tsx new file mode 100644 index 0000000000..ac8596d9be --- /dev/null +++ b/src/pages/spot/StackedIcon.tsx @@ -0,0 +1,71 @@ +import styled, { css } from 'styled-components'; + +import { Icon, IconName } from '@/components/Icon'; + +export type SecondaryIconPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'; + +export type StackedIconProps = { + primaryIcon: IconName; + secondaryIcon: IconName; + primarySize?: string; + secondarySize?: string; + secondaryPosition?: SecondaryIconPosition; + secondaryOffset?: number; +}; + +export const StackedIcon = ({ + primaryIcon, + secondaryIcon, + primarySize = '1.25rem', + secondarySize = '0.625rem', + secondaryPosition = 'bottom-right', + secondaryOffset = 15, +}: StackedIconProps) => { + return ( +
+ + <$SecondaryIcon + iconName={secondaryIcon} + size={secondarySize} + $offset={secondaryOffset} + $position={secondaryPosition} + /> +
+ ); +}; + +const $SecondaryIcon = styled(Icon)<{ + $offset: number; + $position: SecondaryIconPosition; +}>` + --offset: ${({ $offset }) => $offset}%; + + position: absolute; + outline: 1.5px solid var(--color-layer-4); + border-radius: 9999px; + overflow: hidden; + + ${({ $position }) => + ({ + 'bottom-right': css` + bottom: 0; + right: 0; + transform: translate(var(--offset), var(--offset)); + `, + 'top-right': css` + top: 0; + right: 0; + transform: translate(var(--offset), calc(-1 * var(--offset))); + `, + 'top-left': css` + top: 0; + left: 0; + transform: translate(calc(-1 * var(--offset)), calc(-1 * var(--offset))); + `, + 'bottom-left': css` + bottom: 0; + left: 0; + transform: translate(calc(-1 * var(--offset)), var(--offset)); + `, + })[$position]} +`; diff --git a/src/pages/spot/types.ts b/src/pages/spot/types.ts new file mode 100644 index 0000000000..49312da73e --- /dev/null +++ b/src/pages/spot/types.ts @@ -0,0 +1,23 @@ +export interface SpotHeaderToken { + tokenAddress: string; + name: string; + symbol: string; + logoUrl?: string | null; + volume24hUsd?: number; + priceUsd?: number; + marketCapUsd?: number; + change24hPercent?: number; + fdvUsd?: number; + liquidityUsd?: number; + circulatingSupply?: number; + totalSupply?: number; + buys24hUsd?: number; + sells24hUsd?: number; + holders?: number; + top10HoldersPercent?: number; + devHoldingPercent?: number; + snipersPercent?: number; + bundlersPercent?: number; + insidersPercent?: number; + createdAt?: Date; +} diff --git a/src/state/_store.ts b/src/state/_store.ts index f308f1c8db..e7da305cf8 100644 --- a/src/state/_store.ts +++ b/src/state/_store.ts @@ -27,6 +27,8 @@ import { customCreateMigrate } from './migrations'; import { notificationsSlice } from './notifications'; import { perpetualsSlice } from './perpetuals'; import { rawSlice } from './raw'; +import { spotSlice } from './spot'; +import { spotFormSlice } from './spotForm'; import { swapsSlice } from './swaps'; import { tradeFormSlice } from './tradeForm'; import { getClosePositionFormSummary, getTradeFormSummary } from './tradeFormSelectors'; @@ -47,6 +49,8 @@ const reducers = { triggersForm: triggersFormSlice.reducer, tradeForm: tradeFormSlice.reducer, closePositionForm: closePositionFormSlice.reducer, + spot: spotSlice.reducer, + spotForm: spotFormSlice.reducer, layout: layoutSlice.reducer, localization: localizationSlice.reducer, localOrders: localOrdersSlice.reducer, diff --git a/src/state/accountInfoSelectors.ts b/src/state/accountInfoSelectors.ts index 6d509f3020..1ce457c928 100644 --- a/src/state/accountInfoSelectors.ts +++ b/src/state/accountInfoSelectors.ts @@ -9,3 +9,6 @@ export const getSubaccountId = (state: RootState) => state.wallet.localWallet?.s export const getUserWalletAddress = (state: RootState) => state.wallet.localWallet?.address; export const getUserSourceWalletAddress = (state: RootState) => state.wallet.sourceAccount.address; + +export const getUserSolanaWalletAddress = (state: RootState) => + state.wallet.localWallet?.solanaAddress; diff --git a/src/state/app.ts b/src/state/app.ts index 0c6a314e8d..3a9b4d5e7b 100644 --- a/src/state/app.ts +++ b/src/state/app.ts @@ -10,6 +10,7 @@ export interface AppState { pageLoaded: boolean; initializationError?: string; selectedNetwork: DydxNetwork; + currentPath: string; } const initialState: AppState = { @@ -20,6 +21,7 @@ const initialState: AppState = { defaultValue: DEFAULT_APP_ENVIRONMENT, validateFn: validateAgainstAvailableEnvironments, }), + currentPath: '/', }; export const appSlice = createSlice({ @@ -37,8 +39,15 @@ export const appSlice = createSlice({ setInitializationError: (state: AppState, action: PayloadAction) => { state.initializationError = action.payload; }, + setCurrentPath: (state: AppState, action: PayloadAction) => { + state.currentPath = action.payload; + }, }, }); -export const { initializeLocalization, setSelectedNetwork, setInitializationError } = - appSlice.actions; +export const { + initializeLocalization, + setSelectedNetwork, + setInitializationError, + setCurrentPath, +} = appSlice.actions; diff --git a/src/state/appSelectors.ts b/src/state/appSelectors.ts index 34b8229552..064ace822d 100644 --- a/src/state/appSelectors.ts +++ b/src/state/appSelectors.ts @@ -4,6 +4,7 @@ import type { RootState } from './_store'; export const getSelectedNetwork = (state: RootState) => state.app.selectedNetwork; export const getInitializationError = (state: RootState) => state.app.initializationError; +export const getCurrentPath = (state: RootState) => state.app.currentPath; export const getSelectedDydxChainId = (state: RootState) => ENVIRONMENT_CONFIG_MAP[state.app.selectedNetwork].dydxChainId as DydxChainId; @@ -11,5 +12,8 @@ export const getSelectedDydxChainId = (state: RootState) => export const getMetadataEndpoint = (state: RootState) => ENVIRONMENT_CONFIG_MAP[getSelectedNetwork(state)].endpoints.metadataService; +export const getSpotApiEndpoint = (state: RootState) => + ENVIRONMENT_CONFIG_MAP[getSelectedNetwork(state)].endpoints.spotApi; + export const getGeoCheckEnabled = (state: RootState) => ENVIRONMENT_CONFIG_MAP[getSelectedNetwork(state)].featureFlags.checkForGeo; diff --git a/src/state/appUiConfigs.ts b/src/state/appUiConfigs.ts index d04f3e70e7..14d0d789dc 100644 --- a/src/state/appUiConfigs.ts +++ b/src/state/appUiConfigs.ts @@ -1,3 +1,4 @@ +import { SpotBuyInputType, SpotSellInputType, SpotSide } from '@/bonsai/forms/spot'; import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { AnalyticsEvents } from '@/constants/analytics'; @@ -6,6 +7,28 @@ import { DisplayUnit } from '@/constants/trade'; import { track } from '@/lib/analytics/analytics'; +export type SpotQuickOptions = { + [SpotSide.SELL]: { + [SpotSellInputType.PERCENT]: string[]; + [SpotSellInputType.USD]: string[]; + }; + [SpotSide.BUY]: { + [SpotBuyInputType.USD]: string[]; + [SpotBuyInputType.SOL]: string[]; + }; +}; + +export const DEFAULT_SPOT_QUICK_OPTIONS: SpotQuickOptions = { + [SpotSide.SELL]: { + [SpotSellInputType.PERCENT]: ['10', '25', '50', '100'], + [SpotSellInputType.USD]: ['50', '100', '250', '500'], + }, + [SpotSide.BUY]: { + [SpotBuyInputType.USD]: ['50', '100', '250', '500'], + [SpotBuyInputType.SOL]: ['0.1', '0.25', '0.5', '1'], + }, +}; + export enum AppTheme { Classic = 'Classic', Dark = 'Dark', @@ -38,6 +61,8 @@ export interface AppUIConfigsState { displayUnit: DisplayUnit; shouldHideLaunchableMarkets: boolean; favoritedMarkets: string[]; + spotFavorites: string[]; + spotQuickOptions: SpotQuickOptions; horizontalPanelHeightPx: number; tablePageSizes: { [tableKey: string]: number }; simpleUI: { @@ -54,6 +79,8 @@ export const initialState: AppUIConfigsState = { displayUnit: DisplayUnit.Asset, shouldHideLaunchableMarkets: false, favoritedMarkets: [], + spotFavorites: [], + spotQuickOptions: DEFAULT_SPOT_QUICK_OPTIONS, horizontalPanelHeightPx: 288, tablePageSizes: {}, simpleUI: { @@ -120,6 +147,20 @@ export const appUiConfigsSlice = createSlice({ const newFavoritedMarkets = currentFavoritedMarkets.filter((id) => id !== marketId); state.favoritedMarkets = newFavoritedMarkets; }, + favoriteSpotToken: ( + state: AppUIConfigsState, + { payload: tokenAddress }: PayloadAction + ) => { + const currentFavoritedTokens = state.spotFavorites; + const newFavoritedTokens = [...currentFavoritedTokens, tokenAddress]; + state.spotFavorites = newFavoritedTokens; + }, + unfavoriteSpotToken: ( + state: AppUIConfigsState, + { payload: tokenAddress }: PayloadAction + ) => { + state.spotFavorites = state.spotFavorites.filter((id) => id !== tokenAddress); + }, setTablePageSize: ( state: AppUIConfigsState, { payload: { pageSize, tableId } }: PayloadAction<{ tableId: string; pageSize: number }> @@ -138,6 +179,23 @@ export const appUiConfigsSlice = createSlice({ ) => { state.simpleUI.sortPositionsBy = payload; }, + setSpotQuickOptions: ( + state: AppUIConfigsState, + { + payload, + }: PayloadAction<{ + side: SpotSide; + inputType: SpotBuyInputType | SpotSellInputType; + options: string[]; + }> + ) => { + const { side, inputType, options } = payload; + if (side === SpotSide.BUY) { + state.spotQuickOptions[SpotSide.BUY][inputType as SpotBuyInputType] = options; + } else { + state.spotQuickOptions[SpotSide.SELL][inputType as SpotSellInputType] = options; + } + }, }, }); @@ -154,4 +212,7 @@ export const { setTablePageSize, setSimpleUISortMarketsBy, setSimpleUISortPositionsBy, + favoriteSpotToken, + unfavoriteSpotToken, + setSpotQuickOptions, } = appUiConfigsSlice.actions; diff --git a/src/state/appUiConfigsSelectors.ts b/src/state/appUiConfigsSelectors.ts index b0fc68e9bf..3f233c4094 100644 --- a/src/state/appUiConfigsSelectors.ts +++ b/src/state/appUiConfigsSelectors.ts @@ -52,6 +52,7 @@ export const getShouldHideLaunchableMarkets = (state: RootState) => state.appUiConfigs.shouldHideLaunchableMarkets; export const getFavoritedMarkets = (state: RootState) => state.appUiConfigs.favoritedMarkets; +export const getSpotFavorites = (state: RootState) => state.appUiConfigs.spotFavorites; export const getHorizontalPanelHeightPx = (state: RootState) => state.appUiConfigs.horizontalPanelHeightPx; @@ -61,6 +62,11 @@ export const getIsMarketFavorited = createAppSelector( (favoritedMarkets, marketId) => favoritedMarkets.includes(marketId) ); +export const getIsSpotTokenFavorited = createAppSelector( + [getSpotFavorites, (_, tokenAddress: string) => tokenAddress], + (favorited, tokenAddress) => favorited.includes(tokenAddress) +); + export const getSavedTablePageSize = createAppSelector( [(state) => state.appUiConfigs.tablePageSizes, (_s, id: string) => id], (pageSizes, id) => pageSizes[id] @@ -72,3 +78,6 @@ export const getSimpleUISortMarketsBy = (state: RootState) => export const getSimpleUISortPositionsBy = (state: RootState) => state.appUiConfigs.simpleUI.sortPositionsBy; + +// Spot +export const getSpotQuickOptions = (state: RootState) => state.appUiConfigs.spotQuickOptions; diff --git a/src/state/raw.ts b/src/state/raw.ts index 39d53b0d8a..22acd5da33 100644 --- a/src/state/raw.ts +++ b/src/state/raw.ts @@ -33,7 +33,13 @@ import { IndexerSparklineResponseObject, } from '@/types/indexer/indexerManual'; +import { + SpotApiPortfolioTradesResponse, + SpotApiTokenMetadataResponse, + SpotApiTokenPriceResponse, +} from '@/clients/spotApi'; import { calc } from '@/lib/do'; +import { SpotApiWsWalletPositionsUpdate } from '@/lib/streaming/walletPositionsStreaming'; import { autoBatchAllReducers } from './autoBatchHelpers'; @@ -104,6 +110,13 @@ export interface RawDataState { data: Loadable; price: Loadable; }; + spot: { + solPrice: Loadable; + tokenPrice: Loadable; + tokenMetadata: Loadable; + walletPositions: Loadable; + portfolioTrades: Loadable; + }; } const initialState: RawDataState = { @@ -143,6 +156,13 @@ const initialState: RawDataState = { data: loadableIdle(), price: loadableIdle(), }, + spot: { + solPrice: loadableIdle(), + tokenMetadata: loadableIdle(), + tokenPrice: loadableIdle(), + walletPositions: loadableIdle(), + portfolioTrades: loadableIdle(), + }, }; export const rawSlice = createSlice({ @@ -253,6 +273,36 @@ export const rawSlice = createSlice({ ) => { state.markets.selectedMarketLeverages = action.payload; }, + setSpotSolPrice: ( + state, + action: PayloadAction> + ) => { + state.spot.solPrice = action.payload; + }, + setSpotTokenPrice: ( + state, + action: PayloadAction> + ) => { + state.spot.tokenPrice = action.payload; + }, + setSpotTokenMetadata: ( + state, + action: PayloadAction> + ) => { + state.spot.tokenMetadata = action.payload; + }, + setSpotWalletPositions: ( + state, + action: PayloadAction> + ) => { + state.spot.walletPositions = action.payload; + }, + setSpotPortfolioTrades: ( + state, + action: PayloadAction> + ) => { + state.spot.portfolioTrades = action.payload; + }, }), // orderbook is throttled separately for fine-grained control setOrderbookRaw: ( @@ -340,4 +390,9 @@ export const { setRewardsTokenPrice, setSelectedMarketLeverage, setSelectedMarketLeverages, + setSpotSolPrice, + setSpotTokenPrice, + setSpotTokenMetadata, + setSpotWalletPositions, + setSpotPortfolioTrades, } = rawSlice.actions; diff --git a/src/state/spot.ts b/src/state/spot.ts new file mode 100644 index 0000000000..63d1ed54b7 --- /dev/null +++ b/src/state/spot.ts @@ -0,0 +1,25 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import type { RootState } from './_store'; + +export type SpotState = { + currentSpotToken: string | undefined; +}; + +const initialState: SpotState = { + currentSpotToken: undefined, +}; + +export const spotSlice = createSlice({ + name: 'Spot', + initialState, + reducers: { + setCurrentSpotToken: (state: SpotState, action: PayloadAction) => { + state.currentSpotToken = action.payload; + }, + }, +}); + +export const { setCurrentSpotToken } = spotSlice.actions; + +export const getCurrentSpotToken = (state: RootState) => state.spot.currentSpotToken; diff --git a/src/state/spotForm.ts b/src/state/spotForm.ts new file mode 100644 index 0000000000..b407abfbbc --- /dev/null +++ b/src/state/spotForm.ts @@ -0,0 +1,13 @@ +import { convertVanillaReducerActionsToReduxToolkitReducers } from '@/bonsai/lib/forms'; +import { BonsaiForms } from '@/bonsai/ontology'; +import { createSlice } from '@reduxjs/toolkit'; + +const spotFormReducer = BonsaiForms.SpotFormFns.reducer; + +export const spotFormSlice = createSlice({ + name: 'spotForm', + initialState: spotFormReducer.initialState, + reducers: convertVanillaReducerActionsToReduxToolkitReducers(spotFormReducer), +}); + +export const spotFormActions = spotFormSlice.actions; diff --git a/src/state/spotFormSelectors.ts b/src/state/spotFormSelectors.ts new file mode 100644 index 0000000000..e06b29f242 --- /dev/null +++ b/src/state/spotFormSelectors.ts @@ -0,0 +1,87 @@ +import { SpotFormInputData } from '@/bonsai/forms/spot'; +import { BonsaiCore, BonsaiForms } from '@/bonsai/ontology'; +import { LAMPORTS_PER_SOL } from '@solana/web3.js'; + +import { SpotApiTradeRoute } from '@/clients/spotApi'; +import { isPresent } from '@/lib/typeUtils'; + +import { type RootState } from './_store'; +import { getUserSolanaWalletAddress } from './accountInfoSelectors'; +import { createAppSelector } from './appTypes'; +import { getCurrentSpotToken } from './spot'; + +export const getSpotFormRawState = (state: RootState) => state.spotForm; + +export const getSpotFormInputData = createAppSelector( + [ + BonsaiCore.spot.solPrice.data, + BonsaiCore.spot.tokenMetadata.data, + BonsaiCore.spot.tokenPrice.data, + BonsaiCore.spot.walletPositions.data, + BonsaiCore.spot.solPrice.loading, + BonsaiCore.spot.tokenMetadata.loading, + BonsaiCore.spot.tokenPrice.loading, + BonsaiCore.spot.walletPositions.loading, + getCurrentSpotToken, + getUserSolanaWalletAddress, + ], + ( + solPriceUsd, + tokenMetadata, + tokenPriceUsd, + walletPositions, + solPriceStatus, + tokenMetadataStatus, + tokenPriceStatus, + walletPositionsStatus, + tokenMint, + solanaAddress + ): SpotFormInputData => { + const userSolBalance = isPresent(walletPositions?.solBalance) + ? walletPositions.solBalance / LAMPORTS_PER_SOL + : undefined; + + const userTokenBalance = walletPositions?.tokenBalances.find( + (position) => position.mint === tokenMint + )?.amount; + + const isAsyncDataReady = [ + solPriceStatus, + tokenMetadataStatus, + tokenPriceStatus, + walletPositionsStatus, + ].every((status) => status === 'success'); + + const isRestReady = isPresent(tokenMint) && isPresent(solanaAddress); + + return { + tokenPriceUsd, + solPriceUsd, + userSolBalance, + userTokenBalance, + tokenMint: tokenMetadata?.tokenMint, + decimals: tokenMetadata?.decimals, + pairAddress: tokenMetadata?.pairAddress, + tradeRoute: tokenMetadata?.tradeRoute as SpotApiTradeRoute | undefined, + solanaAddress, + isReady: isRestReady && isAsyncDataReady, + isAsyncDataReady, + isRestReady, + }; + } +); + +export const getSpotFormSummary = createAppSelector( + [getSpotFormRawState, getSpotFormInputData], + (formState, inputData) => { + const summary = BonsaiForms.SpotFormFns.calculateSummary(formState, inputData); + const errors = BonsaiForms.SpotFormFns.getErrors(formState, inputData, summary); + + return { + inputData, + summary, + errors, + state: formState, + }; + } +); diff --git a/src/state/transfers.ts b/src/state/transfers.ts index c701255cd7..3c55d2b426 100644 --- a/src/state/transfers.ts +++ b/src/state/transfers.ts @@ -40,7 +40,18 @@ export type Withdraw = { txHash: string; }; -export type Transfer = Deposit | Withdraw; +export type SpotWithdraw = { + id: string; + type: 'spot-withdraw'; + amount: string; + destinationAddress: string; + txSignature?: string; + status: 'pending' | 'success' | 'error'; + error?: string; + updatedAt?: number; +}; + +export type Transfer = Deposit | Withdraw | SpotWithdraw; export function isDeposit(transfer: Transfer): transfer is Deposit { return transfer.type === 'deposit'; @@ -50,6 +61,10 @@ export function isWithdraw(transfer: Transfer): transfer is Withdraw { return transfer.type === 'withdraw'; } +export function isSpotWithdraw(transfer: Transfer): transfer is SpotWithdraw { + return transfer.type === 'spot-withdraw'; +} + export interface TransferState { transfersByDydxAddress: { [account: DydxAddress]: Transfer[] }; } @@ -156,11 +171,51 @@ export const transfersSlice = createSlice({ transfer.updatedAt = Date.now(); } + return transfer; + }); + }, + addSpotWithdraw: ( + state, + action: PayloadAction<{ dydxAddress: DydxAddress; withdraw: SpotWithdraw }> + ) => { + const { dydxAddress, withdraw } = action.payload; + if (!state.transfersByDydxAddress[dydxAddress]) { + state.transfersByDydxAddress[dydxAddress] = []; + } + + const newWithdraw = { ...withdraw, updatedAt: Date.now() }; + + state.transfersByDydxAddress[dydxAddress].push(newWithdraw); + }, + updateSpotWithdraw: ( + state, + action: PayloadAction<{ + dydxAddress: DydxAddress; + withdrawId: string; + updates: Partial; + }> + ) => { + const { dydxAddress, withdrawId, updates } = action.payload; + const accountTransfers = state.transfersByDydxAddress[dydxAddress]; + if (!accountTransfers?.length) return; + + state.transfersByDydxAddress[dydxAddress] = accountTransfers.map((transfer) => { + if (isSpotWithdraw(transfer) && transfer.id === withdrawId) { + return { ...transfer, updatedAt: Date.now(), ...updates }; + } + return transfer; }); }, }, }); -export const { addDeposit, addWithdraw, onWithdrawBroadcast, updateDeposit, updateWithdraw } = - transfersSlice.actions; +export const { + addDeposit, + addWithdraw, + onWithdrawBroadcast, + updateDeposit, + updateWithdraw, + addSpotWithdraw, + updateSpotWithdraw, +} = transfersSlice.actions; diff --git a/src/state/transfersSelectors.ts b/src/state/transfersSelectors.ts index fe9990da79..9c88901345 100644 --- a/src/state/transfersSelectors.ts +++ b/src/state/transfersSelectors.ts @@ -6,7 +6,15 @@ import { DydxAddress } from '@/constants/wallets'; import type { RootState } from './_store'; import { createAppSelector } from './appTypes'; -import { Deposit, isDeposit, isWithdraw, Transfer, Withdraw } from './transfers'; +import { + Deposit, + isDeposit, + isSpotWithdraw, + isWithdraw, + SpotWithdraw, + Transfer, + Withdraw, +} from './transfers'; export const getTransfersByAddress = (state: RootState) => state.transfers.transfersByDydxAddress; @@ -82,6 +90,15 @@ export const selectWithdraw = createAppSelector( } ); +export const selectSpotWithdraw = createAppSelector( + [selectAllTransfers, (s, id: string) => id], + (allTransfers, id) => { + return allTransfers.find( + (transfer): transfer is SpotWithdraw => isSpotWithdraw(transfer) && transfer.id === id + ); + } +); + export const selectHasNonExpiredPendingWithdraws = createAppSelector( [selectParentSubaccountInfo, getTransfersByAddress], (parentSubaccountInfo, transfersByAddress) => { diff --git a/src/state/wallet.ts b/src/state/wallet.ts index a0a97e7bbc..b539c3da32 100644 --- a/src/state/wallet.ts +++ b/src/state/wallet.ts @@ -16,6 +16,7 @@ export interface WalletState { sourceAccount: SourceAccount; localWallet?: { address?: string; + solanaAddress?: string; subaccountNumber?: number; }; turnkeyEmailOnboardingData?: TurnkeyEmailOnboardingData; @@ -73,7 +74,9 @@ export const walletSlice = createSlice({ }, setLocalWallet: ( state, - { payload }: PayloadAction<{ address?: string; subaccountNumber?: number }> + { + payload, + }: PayloadAction<{ address?: string; solanaAddress?: string; subaccountNumber?: number }> ) => { state.localWallet = payload; }, diff --git a/src/styles/constants.css b/src/styles/constants.css index 2bd23f1df6..1d8b928ba8 100644 --- a/src/styles/constants.css +++ b/src/styles/constants.css @@ -14,6 +14,7 @@ /* Sidebar constants */ --sidebar-width: 20.25rem; + --spot-sidebar-width: 25rem; --collapsed-sidebar-width: 3.5rem; /* Content constants */ diff --git a/src/styles/globalStyle.ts b/src/styles/globalStyle.ts index e340db1ebd..2d270cf541 100644 --- a/src/styles/globalStyle.ts +++ b/src/styles/globalStyle.ts @@ -7,6 +7,8 @@ export const GlobalStyle = createGlobalStyle` --color-green: ${({ theme }) => theme.green}; --color-red: ${({ theme }) => theme.red}; + --color-red-faded: ${({ theme }) => theme.redFaded}; + --color-green-faded: ${({ theme }) => theme.greenFaded}; --color-white-faded: ${({ theme }) => theme.whiteFaded}; --color-layer-0: ${({ theme }) => theme.layer0}; @@ -59,6 +61,8 @@ export const GlobalStyle = createGlobalStyle` --color-risk-medium: ${({ theme }) => theme.riskMedium}; --color-risk-high: ${({ theme }) => theme.riskHigh}; + --color-input-background: ${({ theme }) => theme.inputBackground}; + --hover-filter-base: ${({ theme }) => theme.hoverFilterBase}; --hover-filter-variant: ${({ theme }) => theme.hoverFilterVariant}; --active-filter: ${({ theme }) => theme.activeFilter}; diff --git a/src/styles/themes.ts b/src/styles/themes.ts index 67d65f6131..568961b883 100644 --- a/src/styles/themes.ts +++ b/src/styles/themes.ts @@ -11,6 +11,8 @@ const ClassicThemeBase: () => ThemeColorBase = () => ({ green: ColorToken.Green3, red: ColorToken.Red2, + redFaded: generateFadedColorVariant(ColorToken.Red2, OpacityToken.Opacity16), + greenFaded: generateFadedColorVariant(ColorToken.Green3, OpacityToken.Opacity16), whiteFaded: generateFadedColorVariant(ColorToken.White, OpacityToken.Opacity16), layer0: ColorToken.GrayBlue7, @@ -84,6 +86,8 @@ const DarkThemeBase: () => ThemeColorBase = () => ({ green: ColorToken.Green1, red: ColorToken.Red0, + redFaded: generateFadedColorVariant(ColorToken.Red0, OpacityToken.Opacity16), + greenFaded: generateFadedColorVariant(ColorToken.Green1, OpacityToken.Opacity16), whiteFaded: generateFadedColorVariant(ColorToken.White, OpacityToken.Opacity16), layer0: ColorToken.Black, @@ -157,6 +161,8 @@ const LightThemeBase: () => ThemeColorBase = () => ({ green: ColorToken.Green5, red: ColorToken.Red1, + redFaded: generateFadedColorVariant(ColorToken.Red1, OpacityToken.Opacity16), + greenFaded: generateFadedColorVariant(ColorToken.Green5, OpacityToken.Opacity16), whiteFaded: generateFadedColorVariant(ColorToken.White, OpacityToken.Opacity16), layer0: ColorToken.LightGray7, diff --git a/src/views/Lists/Alerts/SkipTransferNotificationRow.tsx b/src/views/Lists/Alerts/SkipTransferNotificationRow.tsx index ced719285f..74a4886fcb 100644 --- a/src/views/Lists/Alerts/SkipTransferNotificationRow.tsx +++ b/src/views/Lists/Alerts/SkipTransferNotificationRow.tsx @@ -10,7 +10,7 @@ import { useStringGetter } from '@/hooks/useStringGetter'; import { Icon, IconName } from '@/components/Icon'; import { Output, OutputType, ShowSign } from '@/components/Output'; -import { Transfer } from '@/state/transfers'; +import { isSpotWithdraw, Transfer } from '@/state/transfers'; import { MustBigNumber } from '@/lib/numbers'; import { truncateAddress } from '@/lib/wallet'; @@ -29,7 +29,14 @@ export const SkipTransferNotificationRow = ({ }) => { const stringGetter = useStringGetter(); const { dydxAddress } = useAccounts(); - const { type, status, estimatedAmountUsd, finalAmountUsd, updatedAt } = transfer; + const { type, status } = transfer; + + // Skip spot withdrawals for now + if (isSpotWithdraw(transfer)) { + return null; + } + + const { estimatedAmountUsd, finalAmountUsd, updatedAt } = transfer; const transferAmountBN = MustBigNumber(finalAmountUsd ?? estimatedAmountUsd); const isReceiving = type === 'deposit'; const multiplier = isReceiving ? 1 : -1; diff --git a/src/views/charts/TradingView/SpotTvChart.tsx b/src/views/charts/TradingView/SpotTvChart.tsx index fc576d1297..6c2cd8810f 100644 --- a/src/views/charts/TradingView/SpotTvChart.tsx +++ b/src/views/charts/TradingView/SpotTvChart.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import type { TvWidget } from '@/constants/tvchart'; +import { useSpotChartMarketAndResolution } from '@/hooks/tradingView/useSpotChartMarketAndResolution'; import { useSpotTradingView } from '@/hooks/tradingView/useSpotTradingView'; import { useTradingViewTheme } from '@/hooks/tradingView/useTradingViewTheme'; import { useSimpleUiEnabled } from '@/hooks/useSimpleUiEnabled'; @@ -9,16 +10,21 @@ import { useSimpleUiEnabled } from '@/hooks/useSimpleUiEnabled'; import { BaseTvChart } from './BaseTvChart'; export interface SpotTvChartProps { - symbol: string; + tokenMint: string; } -export const SpotTvChart = ({ symbol }: SpotTvChartProps) => { +export const SpotTvChart = ({ tokenMint }: SpotTvChartProps) => { const [tvWidget, setTvWidget] = useState(); const isSimpleUi = useSimpleUiEnabled(); useSpotTradingView({ setTvWidget, - symbol, + tokenMint, + }); + + useSpotChartMarketAndResolution({ + tokenMint, + tvWidget, }); useTradingViewTheme({ tvWidget }); diff --git a/src/views/dialogs/TransferDialogs/TransferStatusDialog.tsx b/src/views/dialogs/TransferDialogs/TransferStatusDialog.tsx index a01d3bfd97..fcdef33991 100644 --- a/src/views/dialogs/TransferDialogs/TransferStatusDialog.tsx +++ b/src/views/dialogs/TransferDialogs/TransferStatusDialog.tsx @@ -8,7 +8,7 @@ import { useStringGetter } from '@/hooks/useStringGetter'; import { Dialog, DialogPlacement } from '@/components/Dialog'; -import { isDeposit, isWithdraw } from '@/state/transfers'; +import { isDeposit, isSpotWithdraw, isWithdraw } from '@/state/transfers'; import { selectTransfer } from '@/state/transfersSelectors'; import { DepositStatus } from './DepositDialog2/DepositForm/DepositStatus'; @@ -36,7 +36,9 @@ export const TransferStatusDialog = ({ chainId={transfer.chainId} /> ) : transfer && isWithdraw(transfer) ? ( - setIsOpen(false)} id={transferId} /> + setIsOpen(false)} id={transferId} type="perps" /> + ) : transfer && isSpotWithdraw(transfer) ? ( + setIsOpen(false)} id={transferId} type="spot" /> ) : null; return ( diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/AddressInput.tsx b/src/views/dialogs/TransferDialogs/WithdrawDialog2/AddressInput.tsx index 921691e02f..7037230a42 100644 --- a/src/views/dialogs/TransferDialogs/WithdrawDialog2/AddressInput.tsx +++ b/src/views/dialogs/TransferDialogs/WithdrawDialog2/AddressInput.tsx @@ -28,6 +28,7 @@ type AddressInputProps = { destinationChain: string; onDestinationClicked: () => void; placeholder?: string; + isChainSelectable?: boolean; }; export const AddressInput = ({ @@ -36,6 +37,7 @@ export const AddressInput = ({ destinationChain, onDestinationClicked, placeholder, + isChainSelectable = true, }: AddressInputProps) => { const stringGetter = useStringGetter(); const { sourceAccount } = useAccounts(); @@ -81,14 +83,14 @@ export const AddressInput = ({ )} <$ChainButton - disabled={sourceAccount.chain === WalletNetworkType.Solana} + disabled={!isChainSelectable || sourceAccount.chain === WalletNetworkType.Solana} onClick={onDestinationClicked} >
{CHAIN_INFO[destinationChain]?.name}
- {sourceAccount.chain !== WalletNetworkType.Solana && ( + {isChainSelectable && sourceAccount.chain !== WalletNetworkType.Solana && ( <$CaretIcon size="10px" iconName={IconName.Caret} /> )} diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/SpotAmountInput.tsx b/src/views/dialogs/TransferDialogs/WithdrawDialog2/SpotAmountInput.tsx new file mode 100644 index 0000000000..2aac2c3440 --- /dev/null +++ b/src/views/dialogs/TransferDialogs/WithdrawDialog2/SpotAmountInput.tsx @@ -0,0 +1,79 @@ +import { Dispatch, SetStateAction } from 'react'; + +import { STRING_KEYS } from '@/constants/localization'; + +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { Output, OutputType } from '@/components/Output'; + +type SpotAmountInputProps = { + amount: string; + setAmount: Dispatch>; + solBalance: string | undefined; + maxWithdrawable: number | undefined; + isPending: boolean; +}; + +export const SpotAmountInput = ({ + amount, + setAmount, + solBalance, + maxWithdrawable, + isPending, +}: SpotAmountInputProps) => { + const stringGetter = useStringGetter(); + + const onClickMax = () => { + if (maxWithdrawable) { + setAmount(maxWithdrawable.toString()); + } + }; + + return ( +
+
+
+ {stringGetter({ key: STRING_KEYS.AMOUNT })} + + {solBalance && ( + <> + + + + )} + + {maxWithdrawable !== undefined && maxWithdrawable > 0 && ( + <> + + + + )} +
+ setAmount(e.target.value)} + /> +
+
+ ); +}; diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/SpotWithdrawForm.tsx b/src/views/dialogs/TransferDialogs/WithdrawDialog2/SpotWithdrawForm.tsx new file mode 100644 index 0000000000..f70e1a391f --- /dev/null +++ b/src/views/dialogs/TransferDialogs/WithdrawDialog2/SpotWithdrawForm.tsx @@ -0,0 +1,220 @@ +import { Dispatch, SetStateAction, useMemo } from 'react'; + +import { ButtonAction } from '@/constants/buttons'; +import { STRING_KEYS } from '@/constants/localization'; +import { SOLANA_MAINNET_ID } from '@/constants/solana'; +import { MIN_SOL_RESERVE, SOLANA_BASE_TRANSACTION_FEE } from '@/constants/spot'; + +import { useAccounts } from '@/hooks/useAccounts'; +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { WarningIcon } from '@/icons'; + +import { Button } from '@/components/Button'; +import { Details } from '@/components/Details'; +import { DiffOutput } from '@/components/DiffOutput'; +import { Output, OutputType } from '@/components/Output'; +import { WithTooltip } from '@/components/WithTooltip'; + +import { useAppDispatch } from '@/state/appTypes'; +import { addSpotWithdraw, SpotWithdraw, updateSpotWithdraw } from '@/state/transfers'; + +import { AttemptBigNumber } from '@/lib/numbers'; + +import { isValidWithdrawalAddress } from '../utils'; +import { AddressInput } from './AddressInput'; +import { SpotAmountInput } from './SpotAmountInput'; +import { useMaxWithdrawableSol, useSolBalance, useWithdrawSol } from './withdrawSpotHooks'; + +// TODO: spot localization + +export const SpotWithdrawForm = ({ + amount, + setAmount, + destinationAddress, + setDestinationAddress, + onWithdrawSigned, +}: { + amount: string; + setAmount: Dispatch>; + destinationAddress: string; + setDestinationAddress: Dispatch>; + onWithdrawSigned: (withdrawId: string) => void; +}) => { + const stringGetter = useStringGetter(); + const dispatch = useAppDispatch(); + const { dydxAddress, solanaAddress } = useAccounts(); + + const { data: solBalance } = useSolBalance(); + const maxWithdrawable = useMaxWithdrawableSol(); + const { mutateAsync: executeWithdraw, isPending } = useWithdrawSol(); + + const amountBN = AttemptBigNumber(amount); + const solBalanceBN = AttemptBigNumber(solBalance); + const updatedBalance = solBalanceBN?.minus(amountBN ?? 0); + + const validationError = useMemo(() => { + if (!destinationAddress || !amount) { + return undefined; + } + + if (!solanaAddress) { + return 'Solana wallet not connected'; + } + + if (!isValidWithdrawalAddress(destinationAddress, SOLANA_MAINNET_ID)) { + return 'Invalid Solana address'; + } + + if (amountBN == null || amountBN.lte(0)) { + return 'Amount must be greater than zero'; + } + + if (solBalanceBN == null) { + return 'Loading balance...'; + } + + if (amountBN.gt(solBalanceBN)) { + return stringGetter({ key: STRING_KEYS.INSUFFICIENT_BALANCE }); + } + + if (amountBN.gt(maxWithdrawable)) { + return `Must keep ${MIN_SOL_RESERVE} SOL reserved for fees`; + } + + return undefined; + }, [ + destinationAddress, + amount, + solanaAddress, + amountBN, + solBalanceBN, + maxWithdrawable, + stringGetter, + ]); + + const withdrawDisabled = !destinationAddress || !amount || !!validationError; + + const handleWithdraw = async () => { + if (withdrawDisabled || !dydxAddress) return; + + const withdrawId = crypto.randomUUID(); + + const spotWithdraw: SpotWithdraw = { + id: withdrawId, + type: 'spot-withdraw', + amount, + destinationAddress, + status: 'pending', + }; + + dispatch(addSpotWithdraw({ withdraw: spotWithdraw, dydxAddress })); + onWithdrawSigned(withdrawId); + + try { + const { signature } = await executeWithdraw({ amount, destinationAddress }); + + dispatch( + updateSpotWithdraw({ + dydxAddress, + withdrawId, + updates: { status: 'success', txSignature: signature }, + }) + ); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + + dispatch( + updateSpotWithdraw({ + dydxAddress, + withdrawId, + updates: { status: 'error', error: errorMessage }, + }) + ); + } + }; + + const receipt = ( +
+ ), + }, + { + key: 'fee', + label: 'Estimated Fee', + value: ( + + ), + }, + ]} + /> + ); + + const buttonInner = validationError ? ( +
+ + + + {stringGetter({ key: STRING_KEYS.WITHDRAW })} +
+ ) : ( + stringGetter({ key: STRING_KEYS.WITHDRAW }) + ); + + return ( +
+ {}} + placeholder={`${stringGetter({ key: STRING_KEYS.ADDRESS })}...`} + isChainSelectable={false} + /> + + + +
+ {receipt} + + +
+
+ ); +}; diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawDialog2.tsx b/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawDialog2.tsx index 22c91cea3b..9da39498e5 100644 --- a/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawDialog2.tsx +++ b/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawDialog2.tsx @@ -11,9 +11,11 @@ import { WalletNetworkType, WalletType } from '@/constants/wallets'; import { useAccounts } from '@/hooks/useAccounts'; import { useBreakpoints } from '@/hooks/useBreakpoints'; +import { useEnableSpot } from '@/hooks/useEnableSpot'; import { useStringGetter } from '@/hooks/useStringGetter'; import { Dialog, DialogPlacement } from '@/components/Dialog'; +import { SpotTabItem, SpotTabs } from '@/pages/spot/SpotTabs'; import { useAppDispatch } from '@/state/appTypes'; import { @@ -24,6 +26,7 @@ import { } from '@/state/transfers'; import { ChainSelect } from './ChainSelect'; +import { SpotWithdrawForm } from './SpotWithdrawForm'; import { WithdrawForm } from './WithdrawForm'; import { WithdrawStatus } from './WithdrawStatus'; @@ -47,9 +50,20 @@ export const WithdrawDialog2 = ({ setIsOpen }: DialogProps) const stringGetter = useStringGetter(); const dispatch = useAppDispatch(); const [amount, setAmount] = useState(''); + const [spotDestinationAddress, setSpotDestinationAddress] = useState(''); const [currentWithdrawId, setCurrentWithdrawId] = useState(); + const [currentWithdrawType, setCurrentWithdrawType] = useState<'perps' | 'spot'>('perps'); const [formState, setFormState] = useState<'form' | 'chain-select'>('form'); const chainSelectRef = useRef(null); + const isSpotEnabled = useEnableSpot(); + + const handleTabChange = (newTab: 'perps' | 'spot') => { + setCurrentWithdrawType(newTab); + setAmount(''); + setDestinationAddress(isPrivy || isTurnkey ? '' : (sourceAccount.address ?? '')); + setSpotDestinationAddress(''); + setFormState('form'); + }; const dialogTitle = formState === 'form' @@ -65,6 +79,10 @@ export const WithdrawDialog2 = ({ setIsOpen }: DialogProps) setCurrentWithdrawId(withdrawId); }; + const onSpotWithdrawSigned = (withdrawId: string) => { + setCurrentWithdrawId(withdrawId); + }; + const onWithdraw = (withdraw: Withdraw) => { if (!dydxAddress) return; dispatch(addWithdraw({ withdraw, dydxAddress })); @@ -78,6 +96,64 @@ export const WithdrawDialog2 = ({ setIsOpen }: DialogProps) dispatch(onWithdrawBroadcast({ dydxAddress, withdrawId, subtransaction })); }; + const tabs: SpotTabItem[] = [ + { + value: 'perps', + label: 'Perpetuals', + content: ( +
+
+ setFormState('chain-select')} + onWithdraw={onWithdraw} + onWithdrawBroadcastUpdate={onWithdrawBroadcastUpdate} + onWithdrawSigned={onWithdrawSigned} + /> +
+
+ +
+
+ ), + }, + { + value: 'spot', + label: 'Spot', + content: ( + + ), + }, + ]; + return ( <$Dialog isOpen @@ -89,43 +165,20 @@ export const WithdrawDialog2 = ({ setIsOpen }: DialogProps) placement={isMobile ? DialogPlacement.FullScreen : DialogPlacement.Default} > {currentWithdrawId && ( - setIsOpen(false)} /> + setIsOpen(false)} + /> )} {!currentWithdrawId && ( -
-
-
- setFormState('chain-select')} - onWithdraw={onWithdraw} - onWithdrawBroadcastUpdate={onWithdrawBroadcastUpdate} - onWithdrawSigned={onWithdrawSigned} - /> -
-
- -
-
+
+ handleTabChange(v as 'perps' | 'spot')} + hideTabs={formState === 'chain-select' || !isSpotEnabled} + items={tabs} + />
)} diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawForm.tsx b/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawForm.tsx index ede8684d7b..673e1b65b6 100644 --- a/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawForm.tsx +++ b/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawForm.tsx @@ -273,7 +273,7 @@ export const WithdrawForm = ({ }; return ( -
+
void; }; -export const WithdrawStatus = ({ id = '', onClose }: WithdrawStatusProps) => { +export const WithdrawStatus = ({ id = '', type = 'perps', onClose }: WithdrawStatusProps) => { const stringGetter = useStringGetter(); - const withdraw = useAppSelectorWithArgs(selectWithdraw, id); + const perpWithdraw = useAppSelectorWithArgs(selectWithdraw, id); + const spotWithdraw = useAppSelectorWithArgs(selectSpotWithdraw, id); + + const withdraw = type === 'spot' ? spotWithdraw : perpWithdraw; const transferSuccess = withdraw?.status === 'success'; const transferError = withdraw?.status === 'error'; - const transferAssetRelease = withdraw?.transferAssetRelease; + const transferAssetRelease = + type === 'perps' && perpWithdraw ? perpWithdraw.transferAssetRelease : undefined; const statusDescription = useMemo(() => { if (transferSuccess) return stringGetter({ key: STRING_KEYS.YOUR_FUNDS_WITHDRAWN }); @@ -41,35 +46,62 @@ export const WithdrawStatus = ({ id = '', onClose }: WithdrawStatusProps) => { }); } + if (type === 'spot' && spotWithdraw?.error) { + return spotWithdraw.error; + } + return stringGetter({ key: STRING_KEYS.WITHDRAWAL_FAILED_TRY_AGAIN }); } return stringGetter({ key: STRING_KEYS.YOUR_FUNDS_WITHDRAWN_SHORTLY }); - }, [stringGetter, transferAssetRelease, transferError, transferSuccess]); + }, [stringGetter, transferAssetRelease, transferError, transferSuccess, type, spotWithdraw]); const withdrawalOutput = withdraw == null ? ( - ) : ( + ) : type === 'spot' && spotWithdraw ? ( + + ) : perpWithdraw ? ( - ); + ) : null; - const withdrawalExplorerLinks = withdraw?.transactions - .map((t) => { - if (!t.explorerLink) return null; + const withdrawalExplorerLinks = + type === 'spot' && spotWithdraw?.txSignature + ? [ + + {truncateAddress(spotWithdraw.txSignature, '')} + , + ] + : type === 'perps' && perpWithdraw + ? perpWithdraw.transactions + .map((t) => { + if (!t.explorerLink) return null; - return ( - - {truncateAddress(t.txHash, '')} - - ); - }) - .filter(isTruthy); + return ( + + {truncateAddress(t.txHash, '')} + + ); + }) + .filter(isTruthy) + : []; return (
@@ -97,7 +129,11 @@ export const WithdrawStatus = ({ id = '', onClose }: WithdrawStatusProps) => {
{stringGetter({ key: STRING_KEYS.YOUR_WITHDRAWAL })}
{withdrawalOutput} - {withdraw && } + {type === 'spot' ? ( + + ) : ( + perpWithdraw && + )}
- {(withdrawalExplorerLinks?.length ?? 0) > 0 && ( + {withdrawalExplorerLinks.length > 0 && (
{stringGetter({ key: STRING_KEYS.VIEW_TRANSACTIONS_SHORT })} diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawSpotHooks.ts b/src/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawSpotHooks.ts new file mode 100644 index 0000000000..d6747d205f --- /dev/null +++ b/src/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawSpotHooks.ts @@ -0,0 +1,103 @@ +import { wrapAndLogBonsaiError } from '@/bonsai/logs'; +import { LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction } from '@solana/web3.js'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { + MIN_SOL_RESERVE, + SOL_WITHDRAWAL_POLL_INTERVAL_MS, + SOL_WITHDRAWAL_TIMEOUT_MS, +} from '@/constants/spot'; +import { timeUnits } from '@/constants/time'; + +import { useAccounts } from '@/hooks/useAccounts'; +import { useSolanaConnection } from '@/hooks/useSolanaConnection'; + +import { promiseWithTimeout } from '@/lib/asyncUtils'; +import { sleep } from '@/lib/timeUtils'; + +export const useWithdrawSol = () => { + const { localSolanaKeypair, solanaAddress } = useAccounts(); + const connection = useSolanaConnection(); + + return useMutation({ + mutationFn: wrapAndLogBonsaiError( + async ({ amount, destinationAddress }: { amount: string; destinationAddress: string }) => { + if (!localSolanaKeypair || !solanaAddress) { + throw new Error('Solana wallet not available'); + } + + const sourcePubkey = new PublicKey(solanaAddress); + const destPubkey = new PublicKey(destinationAddress); + const lamports = Math.floor(parseFloat(amount) * LAMPORTS_PER_SOL); + + const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: sourcePubkey, + toPubkey: destPubkey, + lamports, + }) + ); + + transaction.feePayer = sourcePubkey; + const { blockhash } = await connection.getLatestBlockhash(); + transaction.recentBlockhash = blockhash; + + transaction.sign(localSolanaKeypair); + + const signature = await connection.sendRawTransaction(transaction.serialize()); + + const pollForConfirmation = async (): Promise => { + // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition + while (true) { + // eslint-disable-next-line no-await-in-loop + const status = await connection.getSignatureStatus(signature); + + if ( + status.value?.confirmationStatus === 'confirmed' || + status.value?.confirmationStatus === 'finalized' + ) { + return; + } + + if (status.value?.err) { + throw new Error(`Transaction failed: ${JSON.stringify(status.value.err)}`); + } + + // eslint-disable-next-line no-await-in-loop + await sleep(SOL_WITHDRAWAL_POLL_INTERVAL_MS); + } + }; + + await promiseWithTimeout( + pollForConfirmation(), + SOL_WITHDRAWAL_TIMEOUT_MS, + 'Transaction confirmation timeout' + ); + + return { signature }; + }, + 'spotWithdrawSol' + ), + }); +}; + +export const useSolBalance = () => { + const { solanaAddress } = useAccounts(); + const connection = useSolanaConnection(); + + return useQuery({ + queryKey: ['solBalance', solanaAddress], + queryFn: async () => { + if (!solanaAddress) return '0'; + const lamports = await connection.getBalance(new PublicKey(solanaAddress)); + return (lamports / LAMPORTS_PER_SOL).toString(); + }, + enabled: Boolean(solanaAddress), + refetchInterval: timeUnits.second * 60, + }); +}; + +export const useMaxWithdrawableSol = () => { + const { data: solBalance } = useSolBalance(); + return solBalance ? Math.max(parseFloat(solBalance) - MIN_SOL_RESERVE, 0) : 0; +}; diff --git a/src/views/menus/AccountMenu/AccountMenu.tsx b/src/views/menus/AccountMenu/AccountMenu.tsx index 3ce830bbb0..1ac4873471 100644 --- a/src/views/menus/AccountMenu/AccountMenu.tsx +++ b/src/views/menus/AccountMenu/AccountMenu.tsx @@ -1,35 +1,31 @@ import { ElementType, useMemo } from 'react'; +import { BonsaiCore } from '@/bonsai/ontology'; import { useMfaEnrollment, usePrivy } from '@privy-io/react-auth'; +import { LAMPORTS_PER_SOL } from '@solana/web3.js'; import styled, { css } from 'styled-components'; import tw from 'twin.macro'; import { AMOUNT_RESERVED_FOR_GAS_USDC, OnboardingState } from '@/constants/account'; -import { ButtonAction, ButtonShape, ButtonSize, ButtonType } from '@/constants/buttons'; +import { ButtonAction, ButtonShape, ButtonSize } from '@/constants/buttons'; import { DialogTypes } from '@/constants/dialogs'; -import { STRING_KEYS, TOOLTIP_STRING_KEYS } from '@/constants/localization'; +import { STRING_KEYS } from '@/constants/localization'; import { isDev } from '@/constants/networks'; import { SMALL_USD_DECIMALS, USD_DECIMALS } from '@/constants/numbers'; import { StatsigFlags } from '@/constants/statsig'; -import { - ConnectorType, - DydxChainAsset, - WalletNetworkType, - wallets, - WalletType, -} from '@/constants/wallets'; +import { ConnectorType, DydxChainAsset, wallets, WalletType } from '@/constants/wallets'; import { useAccountBalance } from '@/hooks/useAccountBalance'; import { useAccounts } from '@/hooks/useAccounts'; import { useBreakpoints } from '@/hooks/useBreakpoints'; import { useComplianceState } from '@/hooks/useComplianceState'; +import { useEnableSpot } from '@/hooks/useEnableSpot'; import { useEnvFeatures } from '@/hooks/useEnvFeatures'; import { useMobileAppUrl } from '@/hooks/useMobileAppUrl'; import { useStatsigGateValue } from '@/hooks/useStatsig'; import { useStringGetter } from '@/hooks/useStringGetter'; import { useSubaccount } from '@/hooks/useSubaccount'; import { useTokenConfigs } from '@/hooks/useTokenConfigs'; -import { useURLConfigs } from '@/hooks/useURLConfigs'; import { AppleIcon, AppleLightIcon, DiscordIcon, GoogleIcon, TwitterIcon } from '@/icons'; import { headerMixins } from '@/styles/headerMixins'; @@ -43,7 +39,6 @@ import { IconButton } from '@/components/IconButton'; import { Output, OutputType } from '@/components/Output'; import { Tag, TagSign } from '@/components/Tag'; import { WalletIcon } from '@/components/WalletIcon'; -import { WithTooltip } from '@/components/WithTooltip'; import { MobileDownloadLinks } from '@/views/MobileDownloadLinks'; import { OnboardingTriggerButton } from '@/views/dialogs/OnboardingTriggerButton'; @@ -58,20 +53,24 @@ import { isTruthy } from '@/lib/isTruthy'; import { MustBigNumber } from '@/lib/numbers'; import { truncateAddress } from '@/lib/wallet'; +import { SpotActions } from './SpotActions'; import { SubaccountActions } from './SubaccountActions'; import { WalletActions } from './WalletActions'; +// TODO: spot localization + export const AccountMenu = () => { const stringGetter = useStringGetter(); - const { mintscanBase } = useURLConfigs(); const { isTablet } = useBreakpoints(); const { complianceState } = useComplianceState(); const affiliatesEnabled = useStatsigGateValue(StatsigFlags.ffEnableAffiliates); + const spotEnabled = useEnableSpot(); const dispatch = useAppDispatch(); const onboardingState = useAppSelector(getOnboardingState); const freeCollateral = useAppSelector(getSubaccountFreeCollateral); const isKeplr = useAppSelector(selectIsKeplrConnected); const isTurnkey = useAppSelector(selectIsTurnkeyConnected); + const spotWalletData = useAppSelector(BonsaiCore.spot.walletPositions.data); const { nativeTokenBalance, usdcBalance } = useAccountBalance(); @@ -80,19 +79,14 @@ export const AccountMenu = () => { const { debugCompliance } = useEnvFeatures(); const { - sourceAccount: { walletInfo, address }, + sourceAccount: { walletInfo }, dydxAddress, hdKey, + solanaAddress, + canDeriveSolanaWallet, } = useAccounts(); const { registerAffiliate } = useSubaccount(); - let displayAddress: string | undefined; - if (walletInfo?.name === WalletType.Phantom) { - displayAddress = truncateAddress(address, ''); - } else { - displayAddress = truncateAddress(address, '0x'); - } - const privy = usePrivy(); const { google, discord, twitter } = privy.user ?? {}; @@ -178,48 +172,35 @@ export const AccountMenu = () => { slotTopContent={ onboardingState === OnboardingState.AccountConnected && (
- <$AddressRow> - - <$Column> - {walletInfo && walletInfo.name !== WalletType.Keplr ? ( - - ) : ( - <$label>{stringGetter({ key: STRING_KEYS.DYDX_CHAIN_ADDRESS })} - )} - <$Address>{truncateAddress(dydxAddress)} - - <$CopyButton buttonType="icon" value={dydxAddress} shape={ButtonShape.Square} /> - - <$IconButton +
+ {!!walletInfo && canDeriveSolanaWallet && spotEnabled && solanaAddress && ( + <$AddressCopyButton + value={solanaAddress} + size={ButtonSize.XSmall} + shape={ButtonShape.Pill} + copyIconPosition="end" action={ButtonAction.Base} - href={`${mintscanBase}/account/${dydxAddress}`} - iconName={IconName.LinkOut} - shape={ButtonShape.Square} - type={ButtonType.Link} - /> - - - {walletInfo && - walletInfo.name !== WalletType.Privy && - walletInfo.name !== WalletType.Keplr && ( - <$AddressRow> -
- - {walletIcon} -
- <$Column> - <$label>{stringGetter({ key: STRING_KEYS.SOURCE_ADDRESS })} - <$Address>{displayAddress} - - + > + + {stringGetter({ key: STRING_KEYS.SPOT })} + )} + <$AddressCopyButton + value={dydxAddress} + size={ButtonSize.XSmall} + shape={ButtonShape.Pill} + copyIconPosition="end" + action={ButtonAction.Base} + > + + {stringGetter({ key: STRING_KEYS.PERPETUALS })} + +
+ <$Balances>
@@ -287,6 +268,25 @@ export const AccountMenu = () => { withOnboarding />
+ {canDeriveSolanaWallet && spotEnabled && ( +
+
+ <$label> + Spot Sol Balance + + + <$BalanceOutput + type={OutputType.Asset} + value={ + spotWalletData?.solBalance + ? spotWalletData.solBalance / LAMPORTS_PER_SOL + : 0 + } + /> +
+ +
+ )} {showConfirmPendingDeposit && ( <$ConfirmPendingDeposit> @@ -452,59 +452,9 @@ export const AccountMenu = () => { ); }; -const DydxDerivedAddress = ({ - address, - chain, - dydxAddress, -}: { - address?: string; - chain?: WalletNetworkType.Solana | WalletNetworkType.Evm; - dydxAddress?: string; -}) => { - const stringGetter = useStringGetter(); - - const tooltipText = - chain === WalletNetworkType.Solana - ? stringGetter({ - key: TOOLTIP_STRING_KEYS.DYDX_ADDRESS_FROM_SOLANA_BODY, - params: { - DYDX_ADDRESS: {truncateAddress(dydxAddress)}, - SOLANA_ADDRESS: truncateAddress(address, ''), - }, - }) - : stringGetter({ - key: TOOLTIP_STRING_KEYS.DYDX_ADDRESS_FROM_ETHEREUM_BODY, - params: { - DYDX_ADDRESS: {truncateAddress(dydxAddress)}, - EVM_ADDRESS: truncateAddress(address, '0x'), - }, - }); - - return ( - -
{tooltipText}
- - } - > - <$label>{stringGetter({ key: STRING_KEYS.DYDX_CHAIN_ADDRESS })} -
- ); -}; - const $Column = styled.div` ${layoutMixins.column} `; -const $AddressRow = styled.div` - ${layoutMixins.row} - - gap: 0.5rem; - - ${$Column} { - margin-right: 0.5rem; - } -`; const $label = styled.div` ${layoutMixins.row} @@ -581,7 +531,6 @@ const $IconButton = styled(IconButton)` `} `; -const $CopyButton = styled(CopyButton)` - --button-padding: 0 0.25rem; - --button-border: solid var(--border-width) var(--color-layer-6); +const $AddressCopyButton = styled(CopyButton)` + --button-padding: 0 0.375rem; `; diff --git a/src/views/menus/AccountMenu/SpotActions.tsx b/src/views/menus/AccountMenu/SpotActions.tsx new file mode 100644 index 0000000000..dae0e9e7d7 --- /dev/null +++ b/src/views/menus/AccountMenu/SpotActions.tsx @@ -0,0 +1,74 @@ +import { memo } from 'react'; + +import { Item } from '@radix-ui/react-dropdown-menu'; +import styled, { css } from 'styled-components'; + +import { ButtonAction, ButtonShape } from '@/constants/buttons'; +import { DialogTypes } from '@/constants/dialogs'; +import { STRING_KEYS } from '@/constants/localization'; + +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { IconName } from '@/components/Icon'; +import { IconButton } from '@/components/IconButton'; +import { WithTooltip } from '@/components/WithTooltip'; +import { useMaxWithdrawableSol } from '@/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawSpotHooks'; + +import { useAppDispatch } from '@/state/appTypes'; +import { openDialog } from '@/state/dialogs'; + +import { isTruthy } from '@/lib/isTruthy'; + +export const SpotActions = memo(() => { + const dispatch = useAppDispatch(); + const stringGetter = useStringGetter(); + const maxWithdrawable = useMaxWithdrawableSol(); + const hasBalance = maxWithdrawable > 0; + + return ( +
+ {[ + { + dialog: DialogTypes.Deposit2({}), + iconName: IconName.Deposit, + tooltipStringKey: STRING_KEYS.DEPOSIT, + }, + hasBalance && { + dialog: DialogTypes.Withdraw2({}), + iconName: IconName.Withdraw, + tooltipStringKey: STRING_KEYS.WITHDRAW, + }, + ] + .filter(isTruthy) + .map(({ iconName, tooltipStringKey, dialog }) => ( + + + <$IconButton + key={dialog.type} + action={ButtonAction.Base} + shape={ButtonShape.Square} + iconName={iconName} + onClick={() => dispatch(openDialog(dialog))} + /> + + + ))} +
+ ); +}); + +const $IconButton = styled(IconButton)` + --button-padding: 0 0.25rem; + --button-border: solid var(--border-width) var(--color-layer-6); + + ${({ iconName }) => + iconName != null && + [IconName.Withdraw, IconName.Deposit].includes(iconName) && + css` + --button-icon-size: 1.375em; + `} +`; diff --git a/src/views/tables/MarketsTable/FavoriteButton.tsx b/src/views/tables/MarketsTable/FavoriteButton.tsx index bc91bb2ab0..baa7241779 100644 --- a/src/views/tables/MarketsTable/FavoriteButton.tsx +++ b/src/views/tables/MarketsTable/FavoriteButton.tsx @@ -9,28 +9,45 @@ import { useAppSelectorWithArgs } from '@/hooks/useParameterizedSelector'; import { IconName } from '@/components/Icon'; import { IconButton } from '@/components/IconButton'; -import { favoriteMarket, unfavoriteMarket } from '@/state/appUiConfigs'; -import { getIsMarketFavorited } from '@/state/appUiConfigsSelectors'; +import { + favoriteMarket, + favoriteSpotToken, + unfavoriteMarket, + unfavoriteSpotToken, +} from '@/state/appUiConfigs'; +import { getIsMarketFavorited, getIsSpotTokenFavorited } from '@/state/appUiConfigsSelectors'; import { track } from '@/lib/analytics/analytics'; export const FavoriteButton = ({ className, marketId, + variant = 'perp', }: { className?: string; marketId: string; + variant?: 'perp' | 'spot'; }) => { const dispatch = useDispatch(); - const isMarketFavorited = useAppSelectorWithArgs(getIsMarketFavorited, marketId); + const isSpotFavorited = useAppSelectorWithArgs(getIsSpotTokenFavorited, marketId); + const isPerpFavorited = useAppSelectorWithArgs(getIsMarketFavorited, marketId); + const isMarketFavorited = variant === 'spot' ? isSpotFavorited : isPerpFavorited; const onToggle = (newIsFavorited: boolean) => { if (newIsFavorited) { - dispatch(favoriteMarket(marketId)); - track(AnalyticsEvents.FavoriteMarket({ marketId })); + if (variant === 'spot') { + dispatch(favoriteSpotToken(marketId)); + } else { + dispatch(favoriteMarket(marketId)); + track(AnalyticsEvents.FavoriteMarket({ marketId })); + } } else { - dispatch(unfavoriteMarket(marketId)); - track(AnalyticsEvents.UnfavoriteMarket({ marketId })); + if (variant === 'spot') { + dispatch(unfavoriteSpotToken(marketId)); + } else { + dispatch(unfavoriteMarket(marketId)); + track(AnalyticsEvents.UnfavoriteMarket({ marketId })); + } } }; diff --git a/tailwind.config.js b/tailwind.config.js index 30e35a85ce..57b253e4e9 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -19,6 +19,8 @@ export default { black: 'var(--color-black)', green: 'var(--color-green)', red: 'var(--color-red)', + 'red-faded': 'var(--color-red-faded)', + 'green-faded': 'var(--color-green-faded)', 'white-faded': 'var(--color-white-faded)', 'color-layer-0': 'var(--color-layer-0)', @@ -130,6 +132,7 @@ export default { }, borderRadius: ({ theme }) => ({ ...theme('spacing'), + full: '9999px', }), extend: { animation: {