diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 00000000..1a8b27d8 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,29 @@ +# AGENT.md - PillarX Development Guide + +## Build/Test Commands +- `npm run test` - Run tests with Vitest +- `npm run test:watch` - Run tests in watch mode (after linting) +- `npm run test:ci` - Run tests with coverage for CI +- `npm run test:update` - Update test snapshots +- `npm run build` - Build for production +- `npm run dev` - Start development server +- `npm run lint` - Run ESLint +- `npm run lint:fix` - Auto-fix linting issues +- `npm run format` - Format code with Prettier + +## Architecture & Structure +- **Multi-app platform**: Core PillarX with multiple sub-apps in `src/apps/` +- **Active apps**: pillarx-app, the-exchange, token-atlas, deposit, leaderboard +- **Testing**: Vitest + React Testing Library, tests in `__tests__/` or `test/` directories +- **State**: Redux Toolkit (`src/store.ts`) + React Query for server state +- **Styling**: Tailwind CSS + styled-components + Material-UI Joy components +- **Blockchain**: Ethers v5, Viem, multiple wallet connectors (Privy, Reown/WalletConnect) + +## Code Style & Conventions +- **Linting**: Airbnb TypeScript + Prettier integration +- **Imports**: Absolute imports, TypeScript strict mode, no file extensions +- **Components**: Arrow functions or function declarations, no React imports needed (JSX runtime) +- **Quotes**: Single quotes, trailing commas (ES5), 80 char width, 2 space tabs +- **Error handling**: Console.warn/error allowed, no console.log in production +- **Files**: .tsx for React components, .ts for utilities +- **Naming**: camelCase for variables/functions, PascalCase for components diff --git a/package-lock.json b/package-lock.json index df060d31..531f7cab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "moment": "^2.30.1", "patch-package": "^8.0.0", "plausible-tracker": "^0.3.9", + "polished": "^4.3.1", "prettier": "^3.3.3", "prop-types": "^15.8.1", "react": "^18.2.0", @@ -71,6 +72,7 @@ "react-i18next": "^13.4.1", "react-icons": "^4.12.0", "react-intersection-observer": "^9.13.1", + "react-loader-spinner": "^7.0.3", "react-redux": "^9.1.2", "react-router-dom": "^6.18.0", "react-slick": "^0.30.2", @@ -5808,74 +5810,6 @@ "node": ">=8" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/types/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -9778,14 +9712,6 @@ "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz", "integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==" }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -10686,16 +10612,6 @@ "react-dom": ">=16.8" } }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 10" - } - }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -11263,17 +11179,6 @@ "@types/node": "*" } }, - "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, "node_modules/@types/yargs-parser": { "version": "21.0.3", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", @@ -12199,18 +12104,6 @@ "preact": "^10.24.2" } }, - "node_modules/@wagmi/connectors/node_modules/@noble/hashes": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", - "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@wagmi/connectors/node_modules/@safe-global/safe-apps-provider": { "version": "0.18.5", "resolved": "https://registry.npmjs.org/@safe-global/safe-apps-provider/-/safe-apps-provider-0.18.5.tgz", @@ -14181,7 +14074,7 @@ "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "deprecated": "Use your platform's native atob() and btoa() methods instead", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/abitype": { @@ -14250,17 +14143,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "optional": true, - "peer": true, - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -14269,19 +14151,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "optional": true, - "peer": true, - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/address": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", @@ -16826,18 +16695,11 @@ "node": ">=0.10.0" } }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "optional": true, - "peer": true - }, "node_modules/cssstyle": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "devOptional": true, + "dev": true, "peer": true, "dependencies": { "cssom": "~0.3.6" @@ -16850,7 +16712,7 @@ "version": "0.3.8", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/csstype": { @@ -16874,21 +16736,6 @@ "node": ">=0.10" } }, - "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", - "optional": true, - "peer": true, - "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -16979,7 +16826,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/decode-uri-component": { @@ -17338,20 +17185,6 @@ ], "peer": true }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", - "optional": true, - "peer": true, - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/domhandler": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", @@ -17659,19 +17492,6 @@ "resolved": "https://registry.npmjs.org/enquire.js/-/enquire.js-2.1.6.tgz", "integrity": "sha512-/KujNpO+PT63F7Hlpu4h3pE3TokKRHN26JYmQpPyjkRD/N57R7bPDNojMXdi7uveAKjYB7yQnartCxZnFWr0Xw==" }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -17975,7 +17795,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "devOptional": true, + "dev": true, "peer": true, "dependencies": { "esprima": "^4.0.1", @@ -17997,6 +17817,8 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", "optional": true, "peer": true, "engines": { @@ -19147,7 +18969,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "devOptional": true, + "dev": true, "peer": true, "bin": { "esparse": "bin/esparse.js", @@ -21109,19 +20931,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", - "optional": true, - "peer": true, - "dependencies": { - "whatwg-encoding": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", @@ -21291,21 +21100,6 @@ "node": ">=8.0.0" } }, - "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "optional": true, - "peer": true, - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/http-proxy-middleware": { "version": "2.0.9", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", @@ -22077,7 +21871,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/is-regex": { @@ -22535,44 +22329,6 @@ } } }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, "node_modules/jest-jasmine2": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", @@ -23231,74 +22987,6 @@ } } }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-resolve/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-serializer": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", @@ -23328,203 +23016,6 @@ "styled-components": ">= 5" } }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-util/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-util/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-validate/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-validate/node_modules/chalk/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-validate/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -23594,52 +23085,6 @@ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" }, - "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "optional": true, - "peer": true, - "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "canvas": "^2.5.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -24758,7 +24203,7 @@ "version": "2.2.20", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/oauth-sign": { @@ -25234,19 +24679,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "optional": true, - "peer": true, - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -25727,6 +25159,18 @@ "node": ">=10.13.0" } }, + "node_modules/polished": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", + "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/pony-cause": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-2.1.11.tgz", @@ -27513,7 +26957,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/queue-microtask": { @@ -27966,6 +27410,23 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==" }, + "node_modules/react-loader-spinner": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/react-loader-spinner/-/react-loader-spinner-7.0.3.tgz", + "integrity": "sha512-N29VGq7pPHH1/TqDNKc04ij0uFtJpyM2i6M5vXOcbOfrfl7KENguD4SYkrWBNfwpILHfAPqrRGSPrqoQvLOJsQ==", + "license": "MIT", + "dependencies": { + "styled-components": "^6.1.19", + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": ">=17.0.0 <20.0.0", + "react-dom": ">=17.0.0 <20.0.0" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -30410,7 +29871,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/reselect": { @@ -30545,17 +30006,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, "node_modules/restore-cursor": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", @@ -30891,19 +30341,6 @@ "dev": true, "peer": true }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "optional": true, - "peer": true, - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -31791,6 +31228,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, + "license": "BSD-3-Clause", "optional": true, "peer": true, "engines": { @@ -32588,7 +32026,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/synckit": { @@ -33086,7 +32524,7 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "devOptional": true, + "dev": true, "peer": true, "dependencies": { "psl": "^1.1.33", @@ -33098,19 +32536,6 @@ "node": ">=6" } }, - "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "optional": true, - "peer": true, - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/tryer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", @@ -33490,7 +32915,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "devOptional": true, + "dev": true, "peer": true, "engines": { "node": ">= 4.0.0" @@ -33607,7 +33032,7 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "devOptional": true, + "dev": true, "peer": true, "dependencies": { "querystringify": "^2.1.1", @@ -34527,19 +33952,6 @@ "browser-process-hrtime": "^1.0.0" } }, - "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", - "optional": true, - "peer": true, - "dependencies": { - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/wagmi": { "version": "2.14.16", "resolved": "https://registry.npmjs.org/wagmi/-/wagmi-2.14.16.tgz", @@ -34633,16 +34045,6 @@ "resolved": "https://registry.npmjs.org/webfontloader/-/webfontloader-1.6.28.tgz", "integrity": "sha512-Egb0oFEga6f+nSgasH3E0M405Pzn6y3/9tOVanv/DLfa1YBIgcv90L18YyWnvXkRbIM17v5Kv6IT2N6g1x5tvQ==" }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/webpack": { "version": "5.99.9", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.9.tgz", @@ -34946,19 +34348,6 @@ "node": ">=0.8.0" } }, - "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/whatwg-fetch": { "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", @@ -34966,30 +34355,6 @@ "dev": true, "peer": true }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "optional": true, - "peer": true, - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -35573,21 +34938,11 @@ } } }, - "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/xmlhttprequest-ssl": { diff --git a/package.json b/package.json index f527644e..9dd583d0 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "moment": "^2.30.1", "patch-package": "^8.0.0", "plausible-tracker": "^0.3.9", + "polished": "^4.3.1", "prettier": "^3.3.3", "prop-types": "^15.8.1", "react": "^18.2.0", @@ -79,6 +80,7 @@ "react-i18next": "^13.4.1", "react-icons": "^4.12.0", "react-intersection-observer": "^9.13.1", + "react-loader-spinner": "^7.0.3", "react-redux": "^9.1.2", "react-router-dom": "^6.18.0", "react-slick": "^0.30.2", diff --git a/src/apps/gas-tank/assets/arrow-down.svg b/src/apps/gas-tank/assets/arrow-down.svg new file mode 100644 index 00000000..2fb59a3e --- /dev/null +++ b/src/apps/gas-tank/assets/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/gas-tank/assets/close-icon.svg b/src/apps/gas-tank/assets/close-icon.svg new file mode 100644 index 00000000..d5e1a045 --- /dev/null +++ b/src/apps/gas-tank/assets/close-icon.svg @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/src/apps/gas-tank/assets/confirmed-icon.svg b/src/apps/gas-tank/assets/confirmed-icon.svg new file mode 100644 index 00000000..c7179de5 --- /dev/null +++ b/src/apps/gas-tank/assets/confirmed-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/gas-tank/assets/copy-icon.svg b/src/apps/gas-tank/assets/copy-icon.svg new file mode 100644 index 00000000..7d5830a6 --- /dev/null +++ b/src/apps/gas-tank/assets/copy-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/apps/gas-tank/assets/esc-icon.svg b/src/apps/gas-tank/assets/esc-icon.svg new file mode 100644 index 00000000..63d44133 --- /dev/null +++ b/src/apps/gas-tank/assets/esc-icon.svg @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/src/apps/gas-tank/assets/failed-icon.svg b/src/apps/gas-tank/assets/failed-icon.svg new file mode 100644 index 00000000..acfb9313 --- /dev/null +++ b/src/apps/gas-tank/assets/failed-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/gas-tank/assets/gas-tank-icon.png b/src/apps/gas-tank/assets/gas-tank-icon.png new file mode 100644 index 00000000..e86f5f68 Binary files /dev/null and b/src/apps/gas-tank/assets/gas-tank-icon.png differ diff --git a/src/apps/gas-tank/assets/globe-icon.svg b/src/apps/gas-tank/assets/globe-icon.svg new file mode 100644 index 00000000..3c7edee2 --- /dev/null +++ b/src/apps/gas-tank/assets/globe-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/apps/gas-tank/assets/moreinfo-icon.svg b/src/apps/gas-tank/assets/moreinfo-icon.svg new file mode 100644 index 00000000..592b4f9c --- /dev/null +++ b/src/apps/gas-tank/assets/moreinfo-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/apps/gas-tank/assets/new-tab.svg b/src/apps/gas-tank/assets/new-tab.svg new file mode 100644 index 00000000..10f73717 --- /dev/null +++ b/src/apps/gas-tank/assets/new-tab.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/apps/gas-tank/assets/pending.svg b/src/apps/gas-tank/assets/pending.svg new file mode 100644 index 00000000..f3076cfd --- /dev/null +++ b/src/apps/gas-tank/assets/pending.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/gas-tank/assets/refresh-icon.svg b/src/apps/gas-tank/assets/refresh-icon.svg new file mode 100644 index 00000000..42bddd84 --- /dev/null +++ b/src/apps/gas-tank/assets/refresh-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/apps/gas-tank/assets/seach-icon.svg b/src/apps/gas-tank/assets/seach-icon.svg new file mode 100644 index 00000000..a1853e4f --- /dev/null +++ b/src/apps/gas-tank/assets/seach-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/apps/gas-tank/assets/selected-icon.svg b/src/apps/gas-tank/assets/selected-icon.svg new file mode 100644 index 00000000..e7d55d85 --- /dev/null +++ b/src/apps/gas-tank/assets/selected-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/gas-tank/assets/setting-icon.svg b/src/apps/gas-tank/assets/setting-icon.svg new file mode 100644 index 00000000..9adc8527 --- /dev/null +++ b/src/apps/gas-tank/assets/setting-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/gas-tank/assets/transaction-failed-details-icon.svg b/src/apps/gas-tank/assets/transaction-failed-details-icon.svg new file mode 100644 index 00000000..d88584f3 --- /dev/null +++ b/src/apps/gas-tank/assets/transaction-failed-details-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/gas-tank/assets/usd-coin-usdc-logo.png b/src/apps/gas-tank/assets/usd-coin-usdc-logo.png new file mode 100644 index 00000000..452726da Binary files /dev/null and b/src/apps/gas-tank/assets/usd-coin-usdc-logo.png differ diff --git a/src/apps/gas-tank/assets/wallet.svg b/src/apps/gas-tank/assets/wallet.svg new file mode 100644 index 00000000..d81b5262 --- /dev/null +++ b/src/apps/gas-tank/assets/wallet.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/apps/gas-tank/assets/warning.svg b/src/apps/gas-tank/assets/warning.svg new file mode 100644 index 00000000..9a5ae4a9 --- /dev/null +++ b/src/apps/gas-tank/assets/warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/gas-tank/components/GasTank.tsx b/src/apps/gas-tank/components/GasTank.tsx new file mode 100644 index 00000000..c40e6136 --- /dev/null +++ b/src/apps/gas-tank/components/GasTank.tsx @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import styled from 'styled-components'; + +// components +import UniversalGasTank from './UniversalGasTank'; +import GasTankHistory from './GasTankHistory'; + +const GasTank = () => { + return ( + + + + ); +}; + +const Container = styled.div` + margin: 0 auto; + padding: 32px; + + @media (max-width: 768px) { + padding: 16px; + } +`; + +export default GasTank; diff --git a/src/apps/gas-tank/components/GasTankHistory.styles.ts b/src/apps/gas-tank/components/GasTankHistory.styles.ts new file mode 100644 index 00000000..89a85dcd --- /dev/null +++ b/src/apps/gas-tank/components/GasTankHistory.styles.ts @@ -0,0 +1,289 @@ +import styled from 'styled-components'; +import { BaseContainer, colors, typography } from './shared.styles'; + +import { DetailedHTMLProps, HTMLAttributes } from 'react'; + +interface StyledProps extends DetailedHTMLProps, HTMLDivElement> { + $isDeposit: boolean; +} + +export const S = { + Loading: styled.div` + color: ${colors.text.secondary}; + font-size: 14px; + text-align: center; + padding: 24px 0; + `, + + Container: styled.div` + background: transparent; + border: none; + border-radius: 0; + padding: 0; + width: 100%; + color: ${colors.text.primary}; + `, + + TableWrapper: styled.div` + max-height: 400px; + overflow-y: auto; + padding: 0; + + /* Custom scrollbar styles */ + scrollbar-width: thin; + scrollbar-color: transparent transparent; + transition: scrollbar-color 0.3s ease; + + &:hover { + scrollbar-color: ${colors.button.primary} ${colors.background}; + } + + /* WebKit scrollbar styles */ + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 4px; + transition: background 0.3s ease, opacity 0.3s ease; + } + + &:hover::-webkit-scrollbar-thumb { + background: ${colors.button.primary}; + } + + &::-webkit-scrollbar-thumb:hover { + background: ${colors.button.primaryHover}; + } + + /* Auto-hide behavior */ + &:not(:hover)::-webkit-scrollbar-thumb { + opacity: 0; + transition: opacity 0.5s ease 1s; + } + `, + + Header: styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid ${colors.border}; + `, + + Icon: styled.span` + font-size: 18px; + `, + + IconImage: styled.img` + width: 18px; + height: 18px; + object-fit: contain; + `, + + Title: styled.h2` + ${typography.title}; + margin: 0; + `, + + RefreshButton: styled.button` + margin-left: auto; + background: none; + border: none; + color: ${colors.text.secondary}; + font-size: 18px; + cursor: pointer; + padding: 0 4px; + transition: color 0.2s; + &:hover { + color: ${colors.text.primary}; + } + `, + + TableHeader: styled.div` + display: grid; + grid-template-columns: 0.5fr 1.5fr 1fr 1fr 1.5fr; + gap: 24px; + padding: 12px 16px; + border-bottom: 1px solid ${colors.border}; + cursor: pointer; + `, + + HeaderCell: styled.div` + ${typography.small}; + text-transform: uppercase; + letter-spacing: 0.5px; + display: flex; + align-items: center; + gap: 4px; + user-select: none; + + &:first-child { + justify-content: center; /* # column */ + } + + &:nth-child(2) { + justify-content: flex-start; /* Date column */ + } + + &:nth-child(3) { + justify-content: center; /* Type column */ + } + + &:nth-child(4) { + justify-content: center; /* Amount column */ + } + + &:nth-child(5) { + justify-content: flex-start; /* Token column */ + } + `, + + TableBody: styled.div` + width: 100%; + `, + + SortIcon: styled.span` + font-size: 12px; + margin-left: 2px; + `, + + TableRow: styled.div` + display: grid; + grid-template-columns: 0.5fr 1.5fr 1fr 1fr 1.5fr; + gap: 24px; + padding: 12px 16px; + border-bottom: 1px solid ${colors.border}; + transition: background-color 0.2s; + align-items: center; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: ${({ $isDeposit }) => + $isDeposit + ? 'rgba(74, 222, 128, 0.1)' // Success color with opacity + : 'rgba(239, 68, 68, 0.1)'}; // Error color with opacity + } + `, + + IdCell: styled.div` + color: ${colors.text.primary}; + font-size: 14px; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + `, + + DateCell: styled.div` + color: ${colors.text.primary}; + font-size: 14px; + display: flex; + align-items: center; + justify-content: flex-start; + `, + + TypeCell: styled.div` + color: ${colors.text.primary}; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + `, + + AmountCell: styled.div` + color: ${({ $isDeposit }) => + $isDeposit ? colors.status.success : colors.status.error}; + background: none; + padding: 4px 8px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + ${typography.body}; + font-weight: 600; + `, + + TokenCell: styled.div` + display: flex; + align-items: center; + gap: 8px; + `, + + TokenIconContainer: styled.div` + position: relative; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + `, + + TokenIcon: styled.img` + width: 24px; + height: 24px; + border-radius: 50%; + object-fit: cover; + background: ${colors.background}; + `, + + ChainOverlay: styled.img` + position: absolute; + bottom: -2px; + right: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + object-fit: cover; + background: #fff; + border: 1px solid #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + `, + + TokenInfo: styled.div` + display: flex; + flex-direction: column; + gap: 2px; + `, + + TokenValue: styled.span` + color: ${colors.text.primary}; + font-size: 14px; + font-weight: 600; + `, + + TokenSymbol: styled.span` + color: ${colors.text.secondary}; + font-size: 12px; + `, + + NoItemsMsg: styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + color: ${colors.text.secondary}; + font-size: 14px; + font-style: italic; + `, + + ErrorMsg: styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 32px; + color: ${colors.status.error}; + font-size: 14px; + `, +}; \ No newline at end of file diff --git a/src/apps/gas-tank/components/GasTankHistory.tsx b/src/apps/gas-tank/components/GasTankHistory.tsx new file mode 100644 index 00000000..c1ab9206 --- /dev/null +++ b/src/apps/gas-tank/components/GasTankHistory.tsx @@ -0,0 +1,451 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { useEffect, useState, useCallback } from 'react'; +import { useWalletAddress } from '@etherspot/transaction-kit'; +import { formatUnits } from 'viem'; +import { S } from './GasTankHistory.styles'; + +// Import chain logos +import logoArbitrum from '../../../assets/images/logo-arbitrum.png'; +import logoAvalanche from '../../../assets/images/logo-avalanche.png'; +import logoBase from '../../../assets/images/logo-base.png'; +import logoBsc from '../../../assets/images/logo-bsc.png'; +import logoEthereum from '../../../assets/images/logo-ethereum.png'; +import logoGnosis from '../../../assets/images/logo-gnosis.png'; +import logoOptimism from '../../../assets/images/logo-optimism.png'; +import logoPolygon from '../../../assets/images/logo-polygon.png'; +import logoUnknown from '../../../assets/images/logo-unknown.png'; + +// assets +import gasTankIcon from '../assets/gas-tank-icon.png'; + +/** + * Represents a single entry in the gas tank history table. + */ +interface HistoryEntry { + id: string; + date: string; + type: 'Top-up' | 'Spend'; + amount: string; + token: { + symbol: string; + value: string; + icon: string; + chainId: string; + }; +} + +/** + * Keys available for sorting the table. + */ +type SortKey = 'id' | 'date' | 'type' | 'amount' | 'token'; + +/** + * REST API base URL, configurable via environment variable. + */ +const API_URL = import.meta.env.VITE_PAYMASTER_URL || ''; + +/** + * Maps chainId to the corresponding chain logo image + */ +const getChainLogo = (chainId: string): string => { + const chainIdNum = parseInt(chainId); + switch (chainIdNum) { + case 1: // Ethereum + return logoEthereum; + case 137: // Polygon + return logoPolygon; + case 42161: // Arbitrum + return logoArbitrum; + case 10: // Optimism + return logoOptimism; + case 8453: // Base + return logoBase; + case 56: // BSC + return logoBsc; + case 43114: // Avalanche + return logoAvalanche; + case 100: // Gnosis + return logoGnosis; + default: + return logoUnknown; + } +}; + +interface GasTankHistoryProps { + pauseAutoRefresh?: boolean; + overrideLoading?: boolean; + historyData?: HistoryEntry[]; + isLoading?: boolean; + isError?: boolean; + onRefresh?: () => void; +} + +/** + * GasTankHistory component + * Displays a sortable, scrollable table of gas tank transaction history for the connected wallet. + * Handles loading, error, and empty states. Allows manual refresh. + */ +const GasTankHistory = ({ + pauseAutoRefresh = false, + overrideLoading = true, + historyData: externalHistoryData, + isLoading: externalIsLoading, + isError: externalIsError, + onRefresh: externalOnRefresh +}: GasTankHistoryProps) => { + const walletAddress = useWalletAddress(); + const [internalHistoryData, setInternalHistoryData] = useState([]); + const [internalLoading, setInternalLoading] = useState(true); + const [internalError, setInternalError] = useState(false); + const [sortKey, setSortKey] = useState(null); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); + + // Use external data if provided, otherwise use internal data + const historyData = externalHistoryData || internalHistoryData; + const loading = externalIsLoading !== undefined ? externalIsLoading : internalLoading; + const error = externalIsError !== undefined ? externalIsError : internalError; + + /** + * Fetches history data from the REST API and updates state. + * Handles error and loading states. + */ + const fetchHistory = useCallback(() => { + // If external refresh is available, use it instead + if (externalOnRefresh) { + externalOnRefresh(); + return; + } + + if (!walletAddress) return; + setInternalLoading(true); + setInternalError(false); // Reset error before fetching + fetch(`${API_URL}/getGasTankHistory?sender=${walletAddress}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then((res) => { + console.log('Main component response status:', res.status); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + return res.json(); + }) + .then((data) => { + // Map API response to HistoryEntry structure + const entries: HistoryEntry[] = (data.history || []).map((item: any, idx: number) => { + const isDeposit = item.transactionType === 'Deposit'; + return { + id: String(idx + 1), // Numeric id starting from 1 + date: formatTimestamp(item.timestamp), + type: isDeposit ? 'Top-up' : 'Spend', + amount: formatAmount(isDeposit ? item.amountUsd : item.amount, isDeposit), + token: { + symbol: (item.swap && item.swap.length > 0 && item.swap[0]) ? item.swap[0].asset.symbol : 'USDC', + value: (item.swap && item.swap.length > 0 && item.swap[0]) ? item.amount : formatTokenValue(item.amount), + icon: isDeposit + ? (item.swap && item.swap.length > 0 && item.swap[0] + ? item.swap[0].asset.logo + : 'https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694') + : 'https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694', + chainId: item.chainId || '1', + }, + }; + }); + setInternalHistoryData(entries); + }) + .catch((err) => { + console.error('Main component error fetching gas tank history:', err); + setInternalHistoryData([]); + setInternalError(true); // Set error on failure + }) + .finally(() => setInternalLoading(false)); + }, [walletAddress, externalOnRefresh]); + + // Fetch history on wallet address change (only if no external data provided) + useEffect(() => { + if (!externalHistoryData) { + fetchHistory(); + } + }, [fetchHistory, externalHistoryData]); + + // Auto-refresh disabled + // useEffect(() => { + // if (!walletAddress || pauseAutoRefresh) return; + + // const interval = setInterval(() => { + // fetchHistory(); + // }, 30000); // 30 seconds + + // // Cleanup interval on component unmount + // return () => clearInterval(interval); + // }, [walletAddress, fetchHistory, pauseAutoRefresh]); + + /** + * Returns the sort icon for a given column key. + * ⇅ for unsorted, ▲ for ascending, ▼ for descending. + */ + const getSortIcon = (key: SortKey) => { + if (sortKey !== key) return '⇅'; + return sortOrder === 'asc' ? '▲' : '▼'; + }; + + /** + * Handles sorting logic when a column header is clicked. + * Toggles sort order if the same column is clicked. + */ + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortOrder('asc'); + } + }; + + /** + * Returns sorted history data based on selected column and order. + */ + const sortedHistory = [...historyData].sort((a, b) => { + if (!sortKey) return 0; + let valA, valB; + switch (sortKey) { + case 'id': + valA = Number(a.id); + valB = Number(b.id); + break; + case 'date': + valA = new Date(a.date).getTime(); + valB = new Date(b.date).getTime(); + break; + case 'type': + valA = a.type; + valB = b.type; + break; + case 'amount': + valA = Number(a.amount.replace(/[^0-9.-]+/g, '')); + valB = Number(b.amount.replace(/[^0-9.-]+/g, '')); + break; + case 'token': + valA = a.token.value; + valB = b.token.value; + break; + default: + return 0; + } + if (valA < valB) return sortOrder === 'asc' ? -1 : 1; + if (valA > valB) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + + return ( + + {/* Table header with refresh button */} + + + Gas Tank History + + 🔄 + + + + {/* Table content: loading, error, empty, or data */} + {(loading && overrideLoading) ? ( + Loading... + ) : ( + + + + handleSort('id')}> + # + {getSortIcon('id')} + + handleSort('date')}> + Date + {getSortIcon('date')} + + handleSort('type')}> + Type + {getSortIcon('type')} + + handleSort('amount')}> + Amount + {getSortIcon('amount')} + + handleSort('token')}> + Token + {getSortIcon('token')} + + + + {error ? ( + // Error message if API call fails + + Error has occurred while fetching. Please try after some time + + ) : sortedHistory.length === 0 ? ( + // Empty message if no data + No items to display + ) : ( + // Render table rows for each entry + sortedHistory.map((entry) => ( + + {entry.id} + {entry.date} + {entry.type} + + {entry.amount} + + + + + + + + {entry.token.value} + {entry.token.symbol} + + + + )) + )} + + + )} + + ); +}; + +/** + * Converts a UNIX timestamp (seconds) to a formatted date string. + */ +function formatTimestamp(timestamp: string): string { + const date = new Date(Number(timestamp) * 1000); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +/** + * Formats the amount as a USD string, with + for deposit and - for spend. + */ +function formatAmount(amount: string, isDeposit: boolean): string { + let value = amount; + if (!isDeposit) value = Number(formatUnits(BigInt(amount), 6)).toFixed(2); + if (Number(value) <= 0) value = '<0.01'; + return `${isDeposit ? '+' : '-'}$${value}`; +} + +/** + * Formats the token value using USDC decimals (6). + */ +function formatTokenValue(amount: string): string { + try { + // Check if the amount is already a decimal (contains a dot) + if (amount.includes('.')) { + return parseFloat(amount).toFixed(6); + } + // If it's a whole number string, treat it as wei-like format + return formatUnits(BigInt(amount), 6); + } catch (error) { + // Fallback: treat as regular decimal number + return parseFloat(amount).toFixed(6); + } +} + +/** + * Custom hook to fetch and expose gas tank history and total spend. + */ +interface UseGasTankHistoryOptions { + pauseAutoRefresh?: boolean; +} + +export function useGasTankHistory(walletAddress: string | undefined, options: UseGasTankHistoryOptions = {}) { + const { pauseAutoRefresh = false } = options; + const [historyData, setHistoryData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + const [totalSpend, setTotalSpend] = useState(0); + + const fetchHistory = () => { + if (!walletAddress) return; + setLoading(true); + setError(false); + + fetch(`${API_URL}/getGasTankHistory?sender=${walletAddress}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then((res) => { + console.log('Response status:', res.status); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + return res.json(); + }) + .then((data) => { + const entries: HistoryEntry[] = (data.history || []).map((item: any, idx: number) => { + const isDeposit = item.transactionType === 'Deposit'; + return { + id: String(idx + 1), // Numeric id starting from 1 + date: formatTimestamp(item.timestamp), + type: isDeposit ? 'Top-up' : 'Spend', + amount: formatAmount(isDeposit ? item.amountUsd : item.amount, isDeposit), + token: { + symbol: (item.swap && item.swap.length > 0 && item.swap[0]) ? item.swap[0].asset.symbol : 'USDC', + value: (item.swap && item.swap.length > 0 && item.swap[0]) ? item.amount : formatTokenValue(item.amount), + icon: isDeposit + ? (item.swap && item.swap.length > 0 && item.swap[0] + ? item.swap[0].asset.logo + : 'https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694') + : 'https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694', + chainId: item.chainId || '1', + }, + }; + }); + setHistoryData(entries); + // Calculate total spend (sum of all Spend amounts) + const totalSpendCal = entries + .filter((entry) => entry.type === 'Spend') + .reduce((acc, entry) => acc + Number(entry.amount.replace(/[^0-9.-]+/g, '')), 0); + setTotalSpend(Math.abs(totalSpendCal)); + }) + .catch((err) => { + console.error('Error fetching gas tank history:', err); + setHistoryData([]); + setError(true); + }) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + fetchHistory(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [walletAddress]); + + // Auto-refresh disabled + // useEffect(() => { + // if (!walletAddress || pauseAutoRefresh) return; + + // const interval = setInterval(() => { + // fetchHistory(); + // }, 30000); // 30 seconds + + // // Cleanup interval on component unmount + // return () => clearInterval(interval); + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [walletAddress, pauseAutoRefresh]); + + return { historyData, loading, error, totalSpend, refetch: fetchHistory }; +} + +// All styled components moved to GasTankHistory.styles.ts + +export default GasTankHistory; diff --git a/src/apps/gas-tank/components/Misc/Close.tsx b/src/apps/gas-tank/components/Misc/Close.tsx new file mode 100644 index 00000000..71bbd179 --- /dev/null +++ b/src/apps/gas-tank/components/Misc/Close.tsx @@ -0,0 +1,15 @@ +import CloseIcon from '../../assets/close-icon.svg'; + +export default function Close(props: { onClose: () => void }) { + const { onClose } = props; + return ( + + ); +} diff --git a/src/apps/gas-tank/components/Misc/CloseButton.tsx b/src/apps/gas-tank/components/Misc/CloseButton.tsx new file mode 100644 index 00000000..b3e2798d --- /dev/null +++ b/src/apps/gas-tank/components/Misc/CloseButton.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; + +interface CloseButtonProps { + onClose: () => void; +} + +export default function CloseButton(props: CloseButtonProps) { + const { onClose } = props; + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose]); + + return ( + + ); +} diff --git a/src/apps/gas-tank/components/Misc/Esc.tsx b/src/apps/gas-tank/components/Misc/Esc.tsx new file mode 100644 index 00000000..7fa8f855 --- /dev/null +++ b/src/apps/gas-tank/components/Misc/Esc.tsx @@ -0,0 +1,36 @@ +import { useEffect } from 'react'; + +interface EscProps { + onClose: () => void; +} + +export default function Esc(props: EscProps) { + const { onClose } = props; + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose]); + + return ( + + ); +} diff --git a/src/apps/gas-tank/components/Misc/Refresh.tsx b/src/apps/gas-tank/components/Misc/Refresh.tsx new file mode 100644 index 00000000..7138cc84 --- /dev/null +++ b/src/apps/gas-tank/components/Misc/Refresh.tsx @@ -0,0 +1,34 @@ +import { TailSpin } from 'react-loader-spinner'; +import RefreshIcon from '../../assets/refresh-icon.svg'; + +interface RefreshProps { + onClick?: () => void; + disabled?: boolean; + isLoading?: boolean; +} + +export default function Refresh({ + onClick, + disabled = false, + isLoading = false, +}: RefreshProps) { + return ( + + ); +} diff --git a/src/apps/gas-tank/components/Misc/Settings.tsx b/src/apps/gas-tank/components/Misc/Settings.tsx new file mode 100644 index 00000000..29824444 --- /dev/null +++ b/src/apps/gas-tank/components/Misc/Settings.tsx @@ -0,0 +1,13 @@ +import SettingsIcon from '../../assets/setting-icon.svg'; + +export default function Settings() { + return ( + + ); +} diff --git a/src/apps/gas-tank/components/Misc/Tooltip.tsx b/src/apps/gas-tank/components/Misc/Tooltip.tsx new file mode 100644 index 00000000..ce4363fb --- /dev/null +++ b/src/apps/gas-tank/components/Misc/Tooltip.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState } from 'react'; + +interface TooltipProps { + children: React.ReactNode; + content: string; +} + +const Tooltip = ({ children, content }: TooltipProps) => { + const [isVisible, setIsVisible] = useState(false); + const [isPositioned, setIsPositioned] = useState(false); + const [position, setPosition] = useState({ + top: 0, + left: 0, + transform: 'translateX(-50%)', + }); + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + + const calculatePosition = () => { + if (!triggerRef.current || !tooltipRef.current) return; + + const triggerRect = triggerRef.current.getBoundingClientRect(); + const tooltipRect = tooltipRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const tooltipWidth = tooltipRect.width; + const tooltipHeight = tooltipRect.height; + + // Calculate ideal center position + const idealLeft = triggerRect.left + triggerRect.width / 2; + const idealTop = triggerRect.top - tooltipHeight - 8; + + // Calculate boundaries + const margin = 10; + const minLeft = margin; + const maxLeft = viewportWidth - tooltipWidth - margin; + const minTop = margin; + + let finalLeft = idealLeft; + let finalTop = idealTop; + let transform = 'translateX(-50%)'; + + // Adjust horizontal position if tooltip would overflow + if (idealLeft - tooltipWidth / 2 < minLeft) { + // Too far left - align to left edge + finalLeft = minLeft; + transform = 'translateX(0)'; + } else if (idealLeft + tooltipWidth / 2 > viewportWidth - margin) { + // Too far right - align to right edge + finalLeft = maxLeft; + transform = 'translateX(0)'; + } + + // Adjust vertical position if tooltip would overflow above + if (idealTop < minTop) { + // Not enough space above - position below trigger + finalTop = triggerRect.bottom + 8; + transform = transform + .replace('translateX', 'translateX') + .replace('translateY', 'translateY'); + } + + setPosition({ + top: finalTop, + left: finalLeft, + transform, + }); + setIsPositioned(true); + }; + + const handleMouseEnter = () => { + setIsVisible(true); + }; + + const handleMouseLeave = () => { + setIsVisible(false); + setIsPositioned(false); + }; + + useEffect(() => { + if (isVisible && tooltipRef.current) { + // Calculate position after tooltip is rendered + calculatePosition(); + } + }, [isVisible]); + + return ( +
+
+ {children} +
+ {isVisible && ( +
+
+ {content} +
+
+ )} +
+ ); +}; + +export default Tooltip; diff --git a/src/apps/gas-tank/components/Price/TokenPrice.tsx b/src/apps/gas-tank/components/Price/TokenPrice.tsx new file mode 100644 index 00000000..30f31373 --- /dev/null +++ b/src/apps/gas-tank/components/Price/TokenPrice.tsx @@ -0,0 +1,33 @@ +export interface TokenPriceProps { + value: number; +} + +export default function TokenPrice(props: TokenPriceProps): JSX.Element { + const { value } = props; + const fixed = value.toFixed(10); + const parts = fixed.split('.'); + + const decimals = parts[1]; + const firstNonZeroIndex = decimals.search(/[^0]/); + + if (firstNonZeroIndex < 0) { + return

$0.00

; + } + + if (value >= 0.01 || firstNonZeroIndex < 2) { + return

${value.toFixed(5)}

; + } + + const leadingZerosCount = firstNonZeroIndex; + const significantDigits = decimals.slice( + firstNonZeroIndex, + firstNonZeroIndex + 4 + ); + + return ( +

+ $0.0{leadingZerosCount} + {significantDigits} +

+ ); +} diff --git a/src/apps/gas-tank/components/Price/TokenPriceChange.tsx b/src/apps/gas-tank/components/Price/TokenPriceChange.tsx new file mode 100644 index 00000000..a9b05b4e --- /dev/null +++ b/src/apps/gas-tank/components/Price/TokenPriceChange.tsx @@ -0,0 +1,56 @@ +export interface TokenPriceChangeProps { + value: number; +} + +export default function TokenPriceChange( + props: TokenPriceChangeProps +): JSX.Element { + const { value } = props; + const green = ( + + + + ); + + const red = ( + + + + ); + + return ( +
+
0 ? '#5CFF93' : '#FF366C', + }} + > + {value > 0 ? green : red} +

{value < 0 ? (value * -1).toFixed(2) : value.toFixed(2)}%

+
+
+ ); +} diff --git a/src/apps/gas-tank/components/Search/ChainOverlay.tsx b/src/apps/gas-tank/components/Search/ChainOverlay.tsx new file mode 100644 index 00000000..de73f994 --- /dev/null +++ b/src/apps/gas-tank/components/Search/ChainOverlay.tsx @@ -0,0 +1,111 @@ +import { chainNameToChainIdTokensData } from '../../../../services/tokensData'; +import { getLogoForChainId } from '../../../../utils/blockchain'; +import { MobulaChainNames } from '../../utils/constants'; +import GlobeIcon from '../../assets/globe-icon.svg'; +import SelectedIcon from '../../assets/selected-icon.svg'; + +export interface ChainOverlayProps { + setShowChainOverlay: React.Dispatch>; + setOverlayStyle: React.Dispatch>; + setChains: React.Dispatch>; + overlayStyle: React.CSSProperties; + chains: MobulaChainNames; +} + +export default function ChainOverlay(chainOverlayProps: ChainOverlayProps) { + const { + setShowChainOverlay, + setChains, + setOverlayStyle, + overlayStyle, + chains, + } = chainOverlayProps; + return ( + <> +
{ + setShowChainOverlay(false); + setOverlayStyle({}); + }} + /> +
e.stopPropagation()}> +
+ {Object.values(MobulaChainNames).map((chain) => { + const isSelected = chains === chain; + const isAll = chain === MobulaChainNames.All; + let logo = null; + if (isAll) { + logo = ( + + globe-icon + + ); + } else { + const chainId = chainNameToChainIdTokensData(chain); + logo = ( + {chain} + ); + } + return ( +
{ + setChains(chain); + setShowChainOverlay(false); + setOverlayStyle({}); + }} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '10px 18px', + cursor: 'pointer', + background: isSelected ? '#29292F' : 'transparent', + color: isSelected ? '#fff' : '#b0b0b0', + fontWeight: isSelected ? 500 : 400, + fontSize: 16, + position: 'relative', + }} + > + {logo} + + {chain === MobulaChainNames.All ? 'All chains' : chain} + + {isSelected && ( +
+ selected-icon +
+ )} +
+ ); + })} +
+
+ + ); +} diff --git a/src/apps/gas-tank/components/Search/ChainSelect.tsx b/src/apps/gas-tank/components/Search/ChainSelect.tsx new file mode 100644 index 00000000..55b35550 --- /dev/null +++ b/src/apps/gas-tank/components/Search/ChainSelect.tsx @@ -0,0 +1,13 @@ +import GlobeIcon from '../../assets/globe-icon.svg'; + +export default function ChainSelectButton() { + return ( + + ); +} diff --git a/src/apps/gas-tank/components/Search/PortfolioTokenList.tsx b/src/apps/gas-tank/components/Search/PortfolioTokenList.tsx new file mode 100644 index 00000000..a0b3b0a9 --- /dev/null +++ b/src/apps/gas-tank/components/Search/PortfolioTokenList.tsx @@ -0,0 +1,281 @@ +import { TailSpin } from 'react-loader-spinner'; + +// types +import { PortfolioData } from '../../../../types/api'; + +// utils +import { convertPortfolioAPIResponseToToken } from '../../../../services/pillarXApiWalletPortfolio'; +import { + chainNameToChainIdTokensData, + Token, +} from '../../../../services/tokensData'; +import { getLogoForChainId } from '../../../../utils/blockchain'; +import { + formatExponentialSmallNumber, + limitDigitsNumber, +} from '../../../../utils/number'; + +// constants +import { STABLE_CURRENCIES } from '../../constants/tokens'; + +// components +import RandomAvatar from '../../../pillarx-app/components/RandomAvatar/RandomAvatar'; + +type SortKey = 'symbol' | 'price' | 'balance' | 'pnl'; +type SortOrder = 'asc' | 'desc'; + +export interface PortfolioTokenListProps { + walletPortfolioData: PortfolioData | undefined; + handleTokenSelect: (item: Token) => void; + isLoading?: boolean; + isError?: boolean; + searchText?: string; + sortKey?: SortKey | null; + sortOrder?: SortOrder; +} + +const PortfolioTokenList = (props: PortfolioTokenListProps) => { + const { + walletPortfolioData, + handleTokenSelect, + isLoading, + isError, + searchText, + sortKey, + sortOrder = 'desc', + } = props; + + const isStableCurrency = (token: Token) => { + const chainId = chainNameToChainIdTokensData(token.blockchain); + return STABLE_CURRENCIES.some( + (stable) => + stable.chainId === chainId && + stable.address.toLowerCase() === token.contract.toLowerCase() + ); + }; + + // Filter out stable currencies and apply search filter + const getFilteredPortfolioTokens = () => { + if (!walletPortfolioData?.assets) return []; + + let tokens = convertPortfolioAPIResponseToToken(walletPortfolioData) + .filter((token) => !isStableCurrency(token)); + + // Apply search filter if searchText is provided + if (searchText && searchText.trim()) { + const searchLower = searchText.toLowerCase(); + tokens = tokens.filter( + (token: Token) => + token.symbol.toLowerCase().includes(searchLower) || + token.name.toLowerCase().includes(searchLower) || + token.contract.toLowerCase().includes(searchLower) + ); + } + + // Apply sorting + if (sortKey) { + tokens.sort((a: Token, b: Token) => { + let valueA: number | string; + let valueB: number | string; + + switch (sortKey) { + case 'symbol': + valueA = a.symbol.toLowerCase(); + valueB = b.symbol.toLowerCase(); + break; + case 'price': + valueA = a.price || 0; + valueB = b.price || 0; + break; + case 'balance': + valueA = (a.price || 0) * (a.balance || 0); + valueB = (b.price || 0) * (b.balance || 0); + break; + case 'pnl': + valueA = a.price_change_24h || 0; + valueB = b.price_change_24h || 0; + break; + default: + return 0; + } + + if (typeof valueA === 'string' && typeof valueB === 'string') { + return sortOrder === 'asc' + ? valueA.localeCompare(valueB) + : valueB.localeCompare(valueA); + } else { + const numA = Number(valueA); + const numB = Number(valueB); + return sortOrder === 'asc' ? numA - numB : numB - numA; + } + }); + } else { + // Default sort by highest USD balance + tokens.sort((a: Token, b: Token) => { + const balanceUSDA = (a.price || 0) * (a.balance || 0); + const balanceUSDB = (b.price || 0) * (b.balance || 0); + return balanceUSDB - balanceUSDA; + }); + } + + return tokens; + }; + + const portfolioTokens = getFilteredPortfolioTokens(); + + // Loading state + if (isLoading) { + return ( + + +
+ +

Loading your portfolio...

+
+ + + ); + } + + // Error state + if (isError) { + return ( + + +
+

⚠️ Failed to load portfolio

+

+ Unable to fetch your wallet data. Please try again later. +

+
+ + + ); + } + + // No data state + if (!walletPortfolioData) { + return ( + + +
+

🔍 No portfolio data

+

+ Connect your wallet to see your holdings +

+
+ + + ); + } + + // Empty portfolio state (either no tokens or no search results) + if (portfolioTokens.length === 0) { + return ( + + +
+

+ {searchText && searchText.trim() + ? '🔍 No matching tokens found' + : '💰 Portfolio is empty'} +

+

+ {searchText && searchText.trim() + ? `No tokens match '${searchText}' in your portfolio` + : // eslint-disable-next-line quotes + "You don't have any tokens in your portfolio yet"} +

+
+ + + ); + } + + return ( + <> + {portfolioTokens.map((token) => { + const balanceUSD = + token.price && token.balance ? token.price * token.balance : 0; + + return ( + { + handleTokenSelect(token); + }} + > + +
+
+ {token.logo ? ( + token logo + ) : ( +
+ + {token.symbol?.slice(0, 2) || token.name?.slice(0, 2)} + +
+ )} + chain logo +
+
+

+ {token.symbol} +

+

+ $ + {token.price + ? formatExponentialSmallNumber( + limitDigitsNumber(token.price) + ) + : '0.00'} +

+
+
+ + + +
+

+ ${Math.floor(balanceUSD * 100) / 100} +

+

+ {Math.floor((token.balance || 0) * 100000) / 100000} +

+
+ + + +
+

= 0 ? 'text-[#4ADE80]' : 'text-[#F87171]' + }`}> + ${(Math.abs(balanceUSD * (token.price_change_24h || 0) / 100)).toFixed(2)} +

+

= 0 ? 'text-[#4ADE80]' : 'text-[#F87171]' + }`}> + {(token.price_change_24h || 0).toFixed(2)}% +

+
+ + + ); + })} + + ); +}; + +export default PortfolioTokenList; diff --git a/src/apps/gas-tank/components/Search/Search.tsx b/src/apps/gas-tank/components/Search/Search.tsx new file mode 100644 index 00000000..7fe38a0e --- /dev/null +++ b/src/apps/gas-tank/components/Search/Search.tsx @@ -0,0 +1,324 @@ +/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ +import React, { + Dispatch, + SetStateAction, + useEffect, + useRef, + useState, +} from 'react'; +import { TailSpin } from 'react-loader-spinner'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { isAddress } from 'viem'; +import { + Token, + chainNameToChainIdTokensData, +} from '../../../../services/tokensData'; +import { PortfolioData } from '../../../../types/api'; +import { + formatExponentialSmallNumber, + limitDigitsNumber, +} from '../../../../utils/number'; +import SearchIcon from '../../assets/seach-icon.svg'; +import { useTokenSearch } from '../../hooks/useTokenSearch'; +import { SearchType, SelectedToken } from '../../types/tokens'; +import { MobulaChainNames } from '../../utils/constants'; +import { Asset, parseSearchData } from '../../utils/parseSearchData'; +import Close from '../Misc/Close'; +import Esc from '../Misc/Esc'; +import Refresh from '../Misc/Refresh'; +import PortfolioTokenList from './PortfolioTokenList'; + +interface SearchProps { + setSearching: Dispatch>; + isBuy: boolean; + setBuyToken: Dispatch>; + setSellToken: Dispatch>; + chains: MobulaChainNames; + setChains: Dispatch>; + walletPortfolioData?: PortfolioData; + walletPortfolioLoading?: boolean; + walletPortfolioFetching?: boolean; + walletPortfolioError?: boolean; + refetchWalletPortfolio?: () => void; +} + + +type SortKey = 'symbol' | 'price' | 'balance' | 'pnl'; +type SortOrder = 'asc' | 'desc'; + +export default function Search({ + setSearching, + isBuy, + setBuyToken, + setSellToken, + chains, + walletPortfolioData, + walletPortfolioLoading, + walletPortfolioFetching, + walletPortfolioError, + refetchWalletPortfolio, +}: SearchProps) { + const { searchText, setSearchText, searchData, isFetching } = useTokenSearch({ + isBuy, + chains, + }); + const [searchType, setSearchType] = useState(); + const [sortKey, setSortKey] = useState(null); + const [sortOrder, setSortOrder] = useState('desc'); + + const inputRef = useRef(null); + const searchModalRef = useRef(null); + + const useQuery = () => { + const { search } = useLocation(); + return new URLSearchParams(search); + }; + + const query = useQuery(); + + const navigate = useNavigate(); + const location = useLocation(); + + const removeQueryParams = () => { + navigate(location.pathname, { replace: true }); + }; + + useEffect(() => { + inputRef.current?.focus(); + const tokenAddress = query.get('asset'); + + // Only read asset parameter when in buy mode to prevent token address + // from token-atlas showing in sell search + if (isAddress(tokenAddress || '')) { + setSearchText(tokenAddress!); + } + + setSearchType(SearchType.MyHoldings); + }, [query, setSearchText]); + + const handleClose = () => { + setSearchText(''); + // It resets search type to MyHoldings if on sell screen + setSearchType(SearchType.MyHoldings); + setSearching(false); + removeQueryParams(); + }; + + // Click outside to close functionality + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + searchModalRef.current && + !searchModalRef.current.contains(event.target as Node) + ) { + handleClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ESC key to close functionality + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortOrder('desc'); + } + }; + + const getSortIcon = (key: SortKey) => { + if (sortKey !== key) return '↕'; + return sortOrder === 'asc' ? '↑' : '↓'; + }; + + const handleTokenSelect = (item: Asset | Token) => { + if (isBuy) { + // Asset type + if ('chain' in item) { + setBuyToken({ + name: item.name, + symbol: item.symbol, + logo: item.logo ?? '', + usdValue: formatExponentialSmallNumber( + limitDigitsNumber(item.price || 0) + ), + dailyPriceChange: -0.02, + chainId: chainNameToChainIdTokensData(item.chain), + decimals: item.decimals, + address: item.contract, + }); + } else { + // Token type + setBuyToken({ + name: item.name, + symbol: item.symbol, + logo: item.logo ?? '', + usdValue: formatExponentialSmallNumber( + limitDigitsNumber(item.price || 0) + ), + dailyPriceChange: -0.02, + chainId: chainNameToChainIdTokensData(item.blockchain), + decimals: item.decimals, + address: item.contract, + }); + } + } else { + const sellTokenData = { + name: item.name, + symbol: item.symbol, + logo: item.logo ?? '', + usdValue: formatExponentialSmallNumber( + limitDigitsNumber(item.price || 0) + ), + dailyPriceChange: -0.02, + decimals: item.decimals, + address: item.contract, + }; + + if ('chain' in item) { + setSellToken({ + ...sellTokenData, + chainId: chainNameToChainIdTokensData(item.chain), + }); + } else { + setSellToken({ + ...sellTokenData, + chainId: chainNameToChainIdTokensData(item.blockchain), + }); + } + } + setSearchText(''); + // This keeps MyHoldings filter active when on sell screen + if (!isBuy) { + setSearchType(SearchType.MyHoldings); + } + setSearching(false); + removeQueryParams(); + }; + + return ( +
+
+ {/* Fixed Header Section */} +
+
+
+ + search-icon + + { + setSearchText(e.target.value); + }} + /> + {(searchText.length > 0 && isFetching) ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+
+ +
+
+ +
+
+
+ + {/* MyHoldings Header */} +
+
+

+ 💰My Holdings +

+
+
+
+ + {/* Scrollable Content Section */} +
+
+ + + + + + + + + + + +
handleSort('symbol')} + > + Token/Price {getSortIcon('symbol')} + handleSort('balance')} + > + Balance {getSortIcon('balance')} + handleSort('pnl')} + > + Unrealized PnL/% {getSortIcon('pnl')} +
+
+
+
+
+ ); +} diff --git a/src/apps/gas-tank/components/Search/Sort.tsx b/src/apps/gas-tank/components/Search/Sort.tsx new file mode 100644 index 00000000..49726716 --- /dev/null +++ b/src/apps/gas-tank/components/Search/Sort.tsx @@ -0,0 +1,29 @@ +import { SortType } from '../../types/tokens'; + +export interface SortProps { + sortType?: SortType; +} + +export default function Sort(props: SortProps) { + const { sortType } = props; + return ( + + + + + ); +} diff --git a/src/apps/gas-tank/components/Search/TokenList.tsx b/src/apps/gas-tank/components/Search/TokenList.tsx new file mode 100644 index 00000000..5e73882c --- /dev/null +++ b/src/apps/gas-tank/components/Search/TokenList.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react'; +import { Asset } from '../../utils/parseSearchData'; +import RandomAvatar from '../../../pillarx-app/components/RandomAvatar/RandomAvatar'; +import { getLogoForChainId } from '../../../../utils/blockchain'; +import { chainNameToChainIdTokensData } from '../../../../services/tokensData'; +import { SearchType, SortType } from '../../types/tokens'; +import { formatBigNumber } from '../../utils/number'; +import TokenPrice from '../Price/TokenPrice'; +import TokenPriceChange from '../Price/TokenPriceChange'; + +export interface TokenListProps { + assets: Asset[]; + handleTokenSelect: (item: Asset) => void; + searchType?: SearchType; +} + +export default function TokenList(props: TokenListProps) { + const { assets, handleTokenSelect, searchType } = props; + + const [sort, setSort] = useState<{ + mCap?: SortType; + volume?: SortType; + price?: SortType; + priceChange24h?: SortType; + }>({}); + + const handleSortChange = ( + key: 'mCap' | 'volume' | 'price' | 'priceChange24h' + ) => { + const sortType = + // eslint-disable-next-line no-nested-ternary + sort[key] === SortType.Down + ? SortType.Up + : sort[key] === SortType.Up + ? SortType.Down + : SortType.Up; + + assets.sort((a, b) => { + if (sortType === SortType.Up) { + return (b[key] || 0) - (a[key] || 0); + } + return (a[key] || 0) - (b[key] || 0); + }); + + setSort({ + mCap: undefined, + price: undefined, + priceChange24h: undefined, + volume: undefined, + [key]: sortType, + }); + }; + + if (assets) { + return ( + <> + {assets.map((item) => { + return ( + + ); + })} + + ); + } + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; +} diff --git a/src/apps/gas-tank/components/Search/tests/PortfolioTokenList.test.tsx b/src/apps/gas-tank/components/Search/tests/PortfolioTokenList.test.tsx new file mode 100644 index 00000000..02c86cb3 --- /dev/null +++ b/src/apps/gas-tank/components/Search/tests/PortfolioTokenList.test.tsx @@ -0,0 +1,346 @@ +/* eslint-disable quotes */ +/* eslint-disable react/jsx-props-no-spreading */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { render, screen } from '@testing-library/react'; +import renderer from 'react-test-renderer'; +import { vi } from 'vitest'; + +// types +import { PortfolioData } from '../../../../../types/api'; + +// components +import PortfolioTokenList from '../PortfolioTokenList'; + +const mockHandleTokenSelect = vi.fn(); + +const mockPortfolioData: PortfolioData = { + assets: [ + { + asset: { + id: 1, + name: 'Test Token', + symbol: 'TEST', + logo: 'https://example.com/logo.png', + decimals: ['18'], + contracts: ['0x1234567890123456789012345678901234567890'], + blockchains: ['ethereum'], + }, + contracts_balances: [ + { + address: '0x1234567890123456789012345678901234567890', + balance: 1.0, + balanceRaw: '1000000000000000000', + chainId: 'eip155:1', + decimals: 18, + }, + ], + cross_chain_balances: {}, + price_change_24h: 0.05, + estimated_balance: 1.5, + price: 1.5, + token_balance: 1.0, + allocation: 0.3, + wallets: ['0x1234567890123456789012345678901234567890'], + }, + { + asset: { + id: 2, + name: 'Another Token', + symbol: 'ANOTHER', + logo: '', + decimals: ['18'], + contracts: ['0x0987654321098765432109876543210987654321'], + blockchains: ['ethereum'], + }, + contracts_balances: [ + { + address: '0x0987654321098765432109876543210987654321', + balance: 2.0, + balanceRaw: '2000000000000000000', + chainId: 'eip155:1', + decimals: 18, + }, + ], + cross_chain_balances: {}, + price_change_24h: -0.02, + estimated_balance: 4.0, + price: 2.0, + token_balance: 2.0, + allocation: 0.7, + wallets: ['0x1234567890123456789012345678901234567890'], + }, + ], + total_wallet_balance: 5.5, + wallets: ['0x1234567890123456789012345678901234567890'], + balances_length: 2, +}; + +const defaultProps = { + walletPortfolioData: mockPortfolioData, + handleTokenSelect: mockHandleTokenSelect, + isLoading: false, + isError: false, + searchText: '', +}; + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly and matches snapshot', () => { + const tree = renderer + .create() + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders loading state', () => { + render(); + + expect(screen.getByText('Loading your portfolio...')).toBeInTheDocument(); + expect(screen.queryByText('Token/Price')).not.toBeInTheDocument(); + }); + + it('renders error state', () => { + render(); + + expect(screen.getByText('⚠️ Failed to load portfolio')).toBeInTheDocument(); + expect( + screen.getByText( + 'Unable to fetch your wallet data. Please try again later.' + ) + ).toBeInTheDocument(); + }); + + it('renders no data state when walletPortfolioData is null', () => { + render( + + ); + + expect(screen.getByText('🔍 No portfolio data')).toBeInTheDocument(); + expect( + screen.getByText('Connect your wallet to see your holdings') + ).toBeInTheDocument(); + }); + + it('renders empty portfolio state', () => { + const emptyPortfolioData: PortfolioData = { + assets: [], + total_wallet_balance: 0, + wallets: [], + balances_length: 0, + }; + + render( + + ); + + expect(screen.getByText('💰 Portfolio is empty')).toBeInTheDocument(); + expect( + screen.getByText("You don't have any tokens in your portfolio yet") + ).toBeInTheDocument(); + }); + + it('renders no matching tokens when search has no results', () => { + render(); + + expect(screen.getByText('🔍 No matching tokens found')).toBeInTheDocument(); + expect( + screen.getByText("No tokens match 'nonexistent' in your portfolio") + ).toBeInTheDocument(); + }); + + it('renders portfolio tokens with correct data', () => { + render(); + + expect(screen.getByText('Token/Price')).toBeInTheDocument(); + expect(screen.getByText('Balance')).toBeInTheDocument(); + expect(screen.getByText('TEST')).toBeInTheDocument(); + expect(screen.getByText('Test Token')).toBeInTheDocument(); + expect(screen.getAllByText('$1.5')).toHaveLength(2); // Price and balance + expect(screen.getByText('ANOTHER')).toBeInTheDocument(); + expect(screen.getByText('Another Token')).toBeInTheDocument(); + expect(screen.getAllByText('$2')).toHaveLength(1); // Only price for ANOTHER + expect(screen.getByText('$4')).toBeInTheDocument(); // Balance for ANOTHER + }); + + it('filters tokens by search text', () => { + render(); + + expect(screen.getByText('TEST')).toBeInTheDocument(); + expect(screen.getByText('Test Token')).toBeInTheDocument(); + expect(screen.queryByText('ANOTHER')).not.toBeInTheDocument(); + expect(screen.queryByText('Another Token')).not.toBeInTheDocument(); + }); + + it('filters tokens by symbol case insensitively', () => { + render(); + + expect(screen.getByText('ANOTHER')).toBeInTheDocument(); + expect(screen.getByText('Another Token')).toBeInTheDocument(); + expect(screen.queryByText('TEST')).not.toBeInTheDocument(); + expect(screen.queryByText('Test Token')).not.toBeInTheDocument(); + }); + + it('filters tokens by contract address', () => { + render(); + + expect(screen.getByText('TEST')).toBeInTheDocument(); + expect(screen.getByText('Test Token')).toBeInTheDocument(); + expect(screen.queryByText('ANOTHER')).not.toBeInTheDocument(); + }); + + it('calls handleTokenSelect when token is clicked', () => { + render(); + + const tokenButton = screen.getByText('TEST').closest('button'); + tokenButton?.click(); + + expect(mockHandleTokenSelect).toHaveBeenCalledWith( + expect.objectContaining({ + symbol: 'TEST', + name: 'Test Token', + contract: '0x1234567890123456789012345678901234567890', + }) + ); + }); + + it('sorts tokens by USD value in descending order', () => { + render(); + + const tokenButtons = screen.getAllByRole('button'); + const firstToken = tokenButtons[0]; + const secondToken = tokenButtons[1]; + + expect(firstToken).toHaveTextContent('ANOTHER'); // Higher USD value (4.0) + expect(secondToken).toHaveTextContent('TEST'); // Lower USD value (1.5) + }); + + it('displays token logos when available', () => { + render(); + + const testTokenImage = screen.getByAltText('token logo'); + expect(testTokenImage).toHaveAttribute( + 'src', + 'https://example.com/logo.png' + ); + }); + + it('displays random avatar when logo is not available', () => { + render(); + + const anotherTokenContainer = screen.getByText('ANOTHER').closest('button'); + const avatarContainer = anotherTokenContainer?.querySelector( + '.w-8.h-8.rounded-full.overflow-hidden' + ); + expect(avatarContainer).toBeInTheDocument(); + }); + + it('displays chain logos for each token', () => { + render(); + + const chainLogos = screen.getAllByAltText('chain logo'); + expect(chainLogos).toHaveLength(2); + }); + + it('handles tokens with zero balance by showing empty portfolio', () => { + const portfolioWithZeroBalance: PortfolioData = { + assets: [ + { + asset: { + id: 3, + name: 'Zero Token', + symbol: 'ZERO', + logo: '', + decimals: ['18'], + contracts: ['0x1234567890123456789012345678901234567890'], + blockchains: ['ethereum'], + }, + contracts_balances: [ + { + address: '0x1234567890123456789012345678901234567890', + balance: 0, + balanceRaw: '0', + chainId: 'eip155:1', + decimals: 18, + }, + ], + cross_chain_balances: {}, + price_change_24h: 0, + estimated_balance: 0, + price: 1.0, + token_balance: 0, + allocation: 0, + wallets: ['0x1234567890123456789012345678901234567890'], + }, + ], + total_wallet_balance: 0, + wallets: ['0x1234567890123456789012345678901234567890'], + balances_length: 1, + }; + + render( + + ); + + // Zero balance tokens are filtered out, so we should see empty portfolio message + expect(screen.getByText('💰 Portfolio is empty')).toBeInTheDocument(); + expect( + screen.getByText("You don't have any tokens in your portfolio yet") + ).toBeInTheDocument(); + }); + + it('handles tokens with null price', () => { + const portfolioWithNullPrice: PortfolioData = { + assets: [ + { + asset: { + id: 4, + name: 'No Price Token', + symbol: 'NOPRICE', + logo: '', + decimals: ['18'], + contracts: ['0x1234567890123456789012345678901234567890'], + blockchains: ['ethereum'], + }, + contracts_balances: [ + { + address: '0x1234567890123456789012345678901234567890', + balance: 1.0, + balanceRaw: '1000000000000000000', + chainId: 'eip155:1', + decimals: 18, + }, + ], + cross_chain_balances: {}, + price_change_24h: 0, + estimated_balance: 0, + price: 0, + token_balance: 1.0, + allocation: 0, + wallets: ['0x1234567890123456789012345678901234567890'], + }, + ], + total_wallet_balance: 0, + wallets: ['0x1234567890123456789012345678901234567890'], + balances_length: 1, + }; + + render( + + ); + + expect(screen.getByText('NOPRICE')).toBeInTheDocument(); + expect(screen.getByText('$0.00')).toBeInTheDocument(); + }); +}); diff --git a/src/apps/gas-tank/components/Search/tests/Search.test.tsx b/src/apps/gas-tank/components/Search/tests/Search.test.tsx new file mode 100644 index 00000000..0899136c --- /dev/null +++ b/src/apps/gas-tank/components/Search/tests/Search.test.tsx @@ -0,0 +1,377 @@ +/* eslint-disable react/jsx-props-no-spreading */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import renderer from 'react-test-renderer'; +import { vi } from 'vitest'; + +// types +import { PortfolioData } from '../../../../../types/api'; + +// hooks +import * as useTokenSearch from '../../../hooks/useTokenSearch'; + +// utils +import { MobulaChainNames } from '../../../utils/constants'; + +// components +import Search from '../Search'; + +// Mock dependencies +vi.mock('../../../hooks/useTokenSearch', () => ({ + useTokenSearch: vi.fn(), +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useLocation: () => ({ + search: '?asset=0x1234567890123456789012345678901234567890', + pathname: '/', + }), + useNavigate: () => vi.fn(), + }; +}); + +const mockSetSearching = vi.fn(); +const mockSetBuyToken = vi.fn(); +const mockSetSellToken = vi.fn(); +const mockSetChains = vi.fn(); + +const mockPortfolioData: PortfolioData = { + assets: [ + { + asset: { + id: 1, + name: 'Test Token', + symbol: 'TEST', + logo: 'https://example.com/logo.png', + decimals: ['18'], + contracts: ['0x1234567890123456789012345678901234567890'], + blockchains: ['ethereum'], + }, + contracts_balances: [ + { + address: '0x1234567890123456789012345678901234567890', + balance: 1.0, + balanceRaw: '1000000000000000000', + chainId: 'eip155:1', + decimals: 18, + }, + ], + cross_chain_balances: {}, + price_change_24h: 0.05, + estimated_balance: 1.5, + price: 1.5, + token_balance: 1.0, + allocation: 1.0, + wallets: ['0x1234567890123456789012345678901234567890'], + }, + ], + total_wallet_balance: 1.5, + wallets: ['0x1234567890123456789012345678901234567890'], + balances_length: 1, +}; + +const defaultProps = { + setSearching: mockSetSearching, + isBuy: true, + setBuyToken: mockSetBuyToken, + setSellToken: mockSetSellToken, + chains: MobulaChainNames.Ethereum, + setChains: mockSetChains, + walletPortfolioData: mockPortfolioData, + walletPortfolioLoading: false, + walletPortfolioError: false, +}; + +const mockUseTokenSearch = { + searchText: '', + setSearchText: vi.fn(), + searchData: null, + isFetching: false, +}; + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks(); + (useTokenSearch.useTokenSearch as any).mockReturnValue(mockUseTokenSearch); + }); + + it('renders correctly and matches snapshot', () => { + const tree = renderer + .create( + + + + ) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders main search interface elements', () => { + render( + + + + ); + + expect(screen.getByTestId('pulse-search-view')).toBeInTheDocument(); + expect(screen.getByTestId('pulse-search-modal')).toBeInTheDocument(); + expect(screen.getByTestId('pulse-search-input')).toBeInTheDocument(); + expect( + screen.getByTestId('pulse-search-filter-buttons') + ).toBeInTheDocument(); + }); + + it('renders buy mode filter buttons', () => { + render( + + + + ); + + expect(screen.getByText('🔥 Trending')).toBeInTheDocument(); + expect(screen.getByText('🌱 Fresh')).toBeInTheDocument(); + expect(screen.getByText('🚀 Top Gainers')).toBeInTheDocument(); + expect(screen.getByText('💰My Holdings')).toBeInTheDocument(); + }); + + it('renders sell mode with only My Holdings', () => { + render( + + + + ); + + expect(screen.getByText('My Holdings')).toBeInTheDocument(); + expect(screen.queryByText('🔥 Trending')).not.toBeInTheDocument(); + expect(screen.queryByText('🌱 Fresh')).not.toBeInTheDocument(); + expect(screen.queryByText('🚀 Top Gainers')).not.toBeInTheDocument(); + }); + + it('handles search input changes', () => { + const mockSetSearchText = vi.fn(); + (useTokenSearch.useTokenSearch as any).mockReturnValue({ + ...mockUseTokenSearch, + setSearchText: mockSetSearchText, + }); + + render( + + + + ); + + const input = screen.getByTestId('pulse-search-input'); + fireEvent.change(input, { target: { value: 'test search' } }); + + expect(mockSetSearchText).toHaveBeenCalledWith('test search'); + }); + + it('handles filter button clicks in buy mode', () => { + render( + + + + ); + + const trendingButton = screen.getByText('🔥 Trending'); + fireEvent.click(trendingButton); + + // Should trigger search type change + expect(screen.getByText('🔥 Trending')).toBeInTheDocument(); + }); + + it('shows loading spinner when fetching', () => { + (useTokenSearch.useTokenSearch as any).mockReturnValue({ + ...mockUseTokenSearch, + isFetching: true, + searchText: 'test', + }); + + render( + + + + ); + + expect(screen.getByTestId('pulse-search-input')).toBeInTheDocument(); + }); + + it('shows close button when not fetching', () => { + (useTokenSearch.useTokenSearch as any).mockReturnValue({ + ...mockUseTokenSearch, + isFetching: false, + searchText: 'test', + }); + + render( + + + + ); + + expect(screen.getByTestId('pulse-search-input')).toBeInTheDocument(); + }); + + it('displays My Holdings text when in sell mode', () => { + render( + + + + ); + + expect(screen.getByText('My Holdings')).toBeInTheDocument(); + }); + + it('handles token selection for buy mode', () => { + render( + + + + ); + + // Test that the component renders without errors + expect(screen.getByTestId('pulse-search-view')).toBeInTheDocument(); + expect(screen.getByTestId('pulse-search-modal')).toBeInTheDocument(); + + // Test that buy mode shows all filter buttons + expect(screen.getByText('🔥 Trending')).toBeInTheDocument(); + expect(screen.getByText('🌱 Fresh')).toBeInTheDocument(); + expect(screen.getByText('🚀 Top Gainers')).toBeInTheDocument(); + expect(screen.getByText('💰My Holdings')).toBeInTheDocument(); + }); + + it('handles token selection for sell mode', () => { + render( + + + + ); + + // Simulate token selection + const tokenButton = screen.getByText('TEST').closest('button'); + if (tokenButton) { + fireEvent.click(tokenButton); + } + + expect(mockSetSellToken).toHaveBeenCalled(); + }); + + it('shows search placeholder when no search text and no parsed assets', () => { + (useTokenSearch.useTokenSearch as any).mockReturnValue({ + ...mockUseTokenSearch, + searchText: '', + }); + + render( + + + + ); + + expect( + screen.getByText('Search by token or paste address...') + ).toBeInTheDocument(); + }); + + it('handles chain overlay toggle', () => { + render( + + + + ); + + const chainButton = screen.getByRole('button', { name: /save/i }); + fireEvent.click(chainButton); + + // Chain overlay should be triggered + expect(chainButton).toBeInTheDocument(); + }); + + it('handles refresh button click', () => { + render( + + + + ); + + const refreshButton = screen.getByRole('button', { name: /refresh/i }); + fireEvent.click(refreshButton); + + expect(refreshButton).toBeInTheDocument(); + }); + + it('handles portfolio loading state', () => { + render( + + + + ); + + // Should still render the main search interface + expect(screen.getByTestId('pulse-search-view')).toBeInTheDocument(); + expect(screen.getByTestId('pulse-search-modal')).toBeInTheDocument(); + }); + + it('handles portfolio error state', () => { + render( + + + + ); + + // Should still render the main search interface + expect(screen.getByTestId('pulse-search-view')).toBeInTheDocument(); + expect(screen.getByTestId('pulse-search-modal')).toBeInTheDocument(); + }); + + it('handles empty portfolio data', () => { + render( + + + + ); + + // Should still render the main search interface + expect(screen.getByTestId('pulse-search-view')).toBeInTheDocument(); + expect(screen.getByTestId('pulse-search-modal')).toBeInTheDocument(); + }); + + it('handles close button click', () => { + render( + + + + ); + + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + expect(mockSetSearching).toHaveBeenCalledWith(false); + }); + + it('auto-focuses search input on mount', () => { + render( + + + + ); + + const input = screen.getByTestId('pulse-search-input'); + expect(input).toBeInTheDocument(); + }); + + it('handles URL asset parameter on mount', () => { + render( + + + + ); + + // Should set search text from URL parameter + expect(screen.getByTestId('pulse-search-input')).toBeInTheDocument(); + }); +}); diff --git a/src/apps/gas-tank/components/Search/tests/__snapshots__/PortfolioTokenList.test.tsx.snap b/src/apps/gas-tank/components/Search/tests/__snapshots__/PortfolioTokenList.test.tsx.snap new file mode 100644 index 00000000..4a46a977 --- /dev/null +++ b/src/apps/gas-tank/components/Search/tests/__snapshots__/PortfolioTokenList.test.tsx.snap @@ -0,0 +1,230 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders correctly and matches snapshot 1`] = ` +[ +
+
+

+ Token/Price +

+
+
+

+ Balance +

+
+
, + , + , +] +`; diff --git a/src/apps/gas-tank/components/Search/tests/__snapshots__/Search.test.tsx.snap b/src/apps/gas-tank/components/Search/tests/__snapshots__/Search.test.tsx.snap new file mode 100644 index 00000000..599a4767 --- /dev/null +++ b/src/apps/gas-tank/components/Search/tests/__snapshots__/Search.test.tsx.snap @@ -0,0 +1,158 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders correctly and matches snapshot 1`] = ` +
+
+
+
+ + search-icon + + + +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+

+ Search by token or paste address... +

+
+
+
+`; diff --git a/src/apps/gas-tank/components/TopUpModal.tsx b/src/apps/gas-tank/components/TopUpModal.tsx new file mode 100644 index 00000000..e8dd5953 --- /dev/null +++ b/src/apps/gas-tank/components/TopUpModal.tsx @@ -0,0 +1,1325 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { useWalletAddress } from '@etherspot/transaction-kit'; +import { CircularProgress } from '@mui/material'; +import { useState, useEffect } from 'react'; +import { BigNumber } from 'ethers'; +import { formatEther, isAddress } from 'viem'; +import styled from 'styled-components'; + +// services +import { + convertPortfolioAPIResponseToToken, + useGetWalletPortfolioQuery, +} from '../../../services/pillarXApiWalletPortfolio'; +import { PortfolioToken } from '../../../services/tokensData'; + +// hooks +import useGlobalTransactionsBatch from '../../../hooks/useGlobalTransactionsBatch'; +import useBottomMenuModal from '../../../hooks/useBottomMenuModal'; +import { useAppDispatch, useAppSelector } from '../hooks/useReducerHooks'; +import useOffer from '../hooks/useOffer'; + +// redux +import { setWalletPortfolio } from '../reducer/gasTankSlice'; + +// utils +import { formatTokenAmount } from '../utils/converters'; +import { getLogoForChainId } from '../../../utils/blockchain'; + +// types +import { PortfolioData } from '../../../types/api'; +import { logExchangeError, logExchangeEvent } from '../utils/sentry'; +import { useTransactionDebugLogger } from '../../../hooks/useTransactionDebugLogger'; + +// Search component +import Search from './Search/Search'; +import { SelectedToken } from '../types/tokens'; +import { getChainId, getChainName, MobulaChainNames } from '../utils/constants'; + +interface TopUpModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess?: () => void; +} + +const USDC_ADDRESSES: { [chainId: number]: string } = { + 137: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', // Polygon + 42161: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // Arbitrum + 10: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism + // Add more chains as needed +}; + +const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { + const paymasterUrl = import.meta.env.VITE_PAYMASTER_URL; + const walletAddress = useWalletAddress(); + const { addToBatch } = useGlobalTransactionsBatch(); + const { showSend, setShowBatchSendModal } = useBottomMenuModal(); + const { getStepTransactions, getBestOffer } = useOffer(); + const dispatch = useAppDispatch(); + const [chains, setChains] = useState(MobulaChainNames.All); + const [selectedToken, setSelectedToken] = useState( + null + ); + const [errorMsg, setErrorMsg] = useState(null); + const [amount, setAmount] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + const [portfolioTokens, setPortfolioTokens] = useState([]); + const [showSwapConfirmation, setShowSwapConfirmation] = useState(false); + const [swapDetails, setSwapDetails] = useState<{ + receiveAmount: string; + bestOffer: any; + swapTransactions: any[]; + } | null>(null); + const [swapAmount, setSwapAmount] = useState(0); + const [showTokenSelection, setShowTokenSelection] = useState(false); + const { transactionDebugLog } = useTransactionDebugLogger(); + + const walletPortfolio = useAppSelector( + (state) => state.swap.walletPortfolio as PortfolioData | undefined + ); + + // Get wallet portfolio + const { + data: walletPortfolioData, + isSuccess: isWalletPortfolioDataSuccess, + error: walletPortfolioDataError, + refetch: refetchWalletPortfolioData, + } = useGetWalletPortfolioQuery( + { wallet: walletAddress || '', isPnl: false }, + { skip: !walletAddress } + ); + + // Convert portfolio data to tokens when data changes + useEffect(() => { + if (walletPortfolioData && isWalletPortfolioDataSuccess) { + dispatch(setWalletPortfolio(walletPortfolioData?.result?.data)); + const tokens = convertPortfolioAPIResponseToToken( + walletPortfolioData.result.data + ); + setPortfolioTokens(tokens); + + } + if (!isWalletPortfolioDataSuccess || walletPortfolioDataError) { + if (walletPortfolioDataError) { + logExchangeError('Failed to fetch wallet portfolio', { "error": walletPortfolioDataError }, { component: 'TopUpModal', action: 'failed_to_fetch_wallet_portfolio' }); + console.error(walletPortfolioDataError); + } + dispatch(setWalletPortfolio(undefined)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + walletPortfolioData, + isWalletPortfolioDataSuccess, + walletPortfolioDataError, + ]); + + useEffect(() => { + setErrorMsg(null); + }, [selectedToken]); + + const handleTopUp = async () => { + if (!selectedToken || !amount || !swapAmount || !walletAddress) return; + + if (USDC_ADDRESSES[selectedToken.chainId] === undefined) { + setErrorMsg('Gas Tank is not supported on the selected token\'s chain.'); + return; + } + // Validate paymaster URL + if (!paymasterUrl) { + setErrorMsg('Service unavailable. Please try again later.'); + return; + } + + // Validate amount + const n = Number(amount); + if (!Number.isFinite(n) || n <= 0) { + setErrorMsg('Enter a valid amount.'); + return; + } + // Validate sufficient balance for the selected token + const portfolioToken = portfolioTokens.find( + (t) => + t.contract.toLowerCase() === selectedToken.address.toLowerCase() && + Number(getChainId(t.blockchain as MobulaChainNames)) === selectedToken.chainId + ); + if (!portfolioToken || Number(portfolioToken.balance) < swapAmount) { + setErrorMsg(`Insufficient ${selectedToken.symbol} balance.`); + return; + } + // Reset error if valid + setErrorMsg(null); + + setIsProcessing(true); + + try { + // Check if token is USDC + const isUSDC = selectedToken.address.toLowerCase() === USDC_ADDRESSES[selectedToken.chainId].toLowerCase(); + + let receiveSwapAmount = swapAmount.toString(); // Default to input amount + + if (!isUSDC) { + // Need to swap to USDC first + try { + const bestOffer = await getBestOffer({ + fromTokenAddress: selectedToken.address, + fromAmount: Number(swapAmount), + fromChainId: selectedToken.chainId, + fromTokenDecimals: selectedToken.decimals, + slippage: 0.03, + }); + if (!bestOffer) { + logExchangeError('No best offer found for swap', {}, { component: 'TopUpModal', action: 'no_best_offer_found' }); + setIsProcessing(false); + setErrorMsg('No best offer found for the swap. Please try a different token or amount.'); + console.warn('No best offer found for swap'); + return; + } + const swapTransactions = await getStepTransactions( + bestOffer.offer, + walletAddress, + portfolioTokens, + Number(amount), + ); + + receiveSwapAmount = bestOffer.tokenAmountToReceive.toString(); + + // Show swap confirmation UI and pause execution + setSwapDetails({ + receiveAmount: receiveSwapAmount, + bestOffer, + swapTransactions + }); + setShowSwapConfirmation(true); + setIsProcessing(false); + return; + } catch (swapError) { + console.error('Error getting swap transactions:', swapError); + console.warn( + 'Failed to get swap route. Please try a different token or amount.' + ); + logExchangeError('Failed to get swap route', { "error": swapError }, { component: 'TopUpModal', action: 'failed_to_get_swap_route' }); + setErrorMsg('Failed to get swap route. Please try a different token or amount.'); + setIsProcessing(false); + return; + } + } + + // Call the paymaster API for USDC deposits + const response = await fetch( + `${paymasterUrl}/getTransactionForDeposit?chainId=${selectedToken.chainId}&amount=${receiveSwapAmount}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Error fetching transaction data:', errorText); + logExchangeError('Failed to fetch transaction data', { "error": errorText }, { component: 'TopUpModal', action: 'failed_to_fetch_transaction_data' }); + setErrorMsg('Failed to fetch transaction data. Please try again with different token or amount.'); + setIsProcessing(false); + return; + } + + const transactionData = await response.json(); + transactionDebugLog('Gas Tank Top-up transaction data', transactionData); + + // Add transactions to batch + if (Array.isArray(transactionData.result)) { + transactionData.result.forEach((tx: {value?: string, to: string, data?: string}, index: number) => { + const value = tx.value || '0'; + // Handle bigint conversion properly + let bigIntValue: bigint; + if (typeof value === 'bigint') { + // If value is already a native bigint, use it directly + bigIntValue = value; + } else if (value) { + // If value exists but is not a bigint, convert it + bigIntValue = BigNumber.from(value).toBigInt(); + } else { + // If value is undefined/null, use 0 + bigIntValue = BigInt(0); + } + + const integerValue = formatEther(bigIntValue); + addToBatch({ + title: `Gas Tank Top-up ${index + 1}/${transactionData.result.length}`, + description: `Add ${amount} ${selectedToken.symbol} to Gas Tank`, + to: tx.to, + value: integerValue, + data: tx.data, + chainId: selectedToken.chainId, + }); + }); + } else { + const value = transactionData.result.value || '0'; + // Handle bigint conversion properly + let bigIntValue: bigint; + if (typeof value === 'bigint') { + // If value is already a native bigint, use it directly + bigIntValue = value; + } else if (value) { + // If value exists but is not a bigint, convert it + bigIntValue = BigNumber.from(value).toBigInt(); + } else { + // If value is undefined/null, use 0 + bigIntValue = BigInt(0); + } + + const integerValue = formatEther(bigIntValue); + // Single transaction + addToBatch({ + title: 'Gas Tank Top-up', + description: `Add ${amount} ${selectedToken.symbol} to Gas Tank`, + to: transactionData.result.to, + value: integerValue, + data: transactionData.result.data, + chainId: selectedToken.chainId, + }); + } + + // Show the send modal with the batched transactions + setShowBatchSendModal(true); + showSend(); + onSuccess?.(); + } catch (error) { + console.error('Error processing top-up:', error); + console.warn('Failed to process top-up. Please try again.'); + logExchangeError('Failed to process top-up', { "error": error }, { component: 'TopUpModal', action: 'failed_to_process_top_up' }); + setErrorMsg('Failed to process top-up. Please try again.'); + } finally { + setIsProcessing(false); + } + }; + + const handleAmountChange = (value: string) => { + setErrorMsg(null); + // Only allow numeric input + if (value === '' || /^\d*\.?\d*$/.test(value)) { + setAmount(value); + } + let tokenAmount = 0; + if (selectedToken) { + tokenAmount = Number(value) / (parseFloat(selectedToken.usdValue) || 1); + } + setSwapAmount(tokenAmount); + }; + + const handleMaxClick = () => { + setErrorMsg(null); + // Since we don't have balance info in SelectedToken, we'll just clear the amount + // In a real implementation, you'd need to fetch balance for the selected token + if (!selectedToken) return; + const portfolioToken = portfolioTokens.find( + (t) => + t.contract.toLowerCase() === selectedToken.address.toLowerCase() && + Number(getChainId(t.blockchain as MobulaChainNames)) === selectedToken.chainId + ); + if (portfolioToken && portfolioToken.balance) { + const maxUsdValue = (Number(portfolioToken.balance) * parseFloat(selectedToken.usdValue)).toString(); + setAmount(maxUsdValue); + } + }; + + const handleConfirmSwap = async () => { + if (!swapDetails || !selectedToken || !walletAddress) return; + + setIsProcessing(true); + setShowSwapConfirmation(false); + + try { + // Add swap transactions to batch + swapDetails.swapTransactions.forEach((tx, index) => { + const value = tx.value || '0'; + // Handle bigint conversion properly + let bigIntValue: bigint; + if (typeof value === 'bigint') { + // If value is already a native bigint, use it directly + bigIntValue = value; + } else if (value) { + // If value exists but is not a bigint, convert it + bigIntValue = BigNumber.from(value).toBigInt(); + } else { + // If value is undefined/null, use 0 + bigIntValue = BigInt(0); + } + + const integerValue = formatEther(bigIntValue); + if (!tx.to || !isAddress(tx.to)) { + setErrorMsg('Invalid transaction target for swap route. Please try again.'); + logExchangeEvent('Invalid tx.to in swap step', 'error', { tx }, { component: 'TopUpModal', action: 'invalid_tx_to' }); + setIsProcessing(false); + return; + } + addToBatch({ + title: `Swap to USDC ${index + 1}/${swapDetails.swapTransactions.length}`, + description: `Convert ${amount} ${selectedToken.symbol} to USDC for Gas Tank`, + to: tx.to, + value: integerValue, + data: tx.data, + chainId: selectedToken.chainId, + }); + }); + + // Continue with the paymaster API call for USDC deposits + const response = await fetch( + `${paymasterUrl}/getTransactionForDeposit?chainId=${selectedToken.chainId}&amount=${swapDetails.receiveAmount}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Error fetching transaction data:', errorText); + logExchangeError('Failed to fetch transaction data', { "error": errorText }, { component: 'TopUpModal', action: 'failed_to_fetch_transaction_data' }); + setErrorMsg('Failed to fetch transaction data. Please try again with different token or amount.'); + setIsProcessing(false); + return; + } + + const transactionData = await response.json(); + transactionDebugLog('Gas Tank Top-up transaction data', transactionData); + + // Add transactions to batch + if (Array.isArray(transactionData.result)) { + transactionData.result.forEach((tx: {value?: string, to: string, data?: string}, index: number) => { + const value = tx.value || '0'; + // Handle bigint conversion properly + let bigIntValue: bigint; + if (typeof value === 'bigint') { + // If value is already a native bigint, use it directly + bigIntValue = value; + } else if (value) { + // If value exists but is not a bigint, convert it + bigIntValue = BigNumber.from(value).toBigInt(); + } else { + // If value is undefined/null, use 0 + bigIntValue = BigInt(0); + } + + const integerValue = formatEther(bigIntValue); + addToBatch({ + title: `Gas Tank Top-up ${index + 1}/${transactionData.result.length}`, + description: `Add ${amount} ${selectedToken.symbol} to Gas Tank`, + to: tx.to, + value: integerValue, + data: tx.data, + chainId: selectedToken.chainId, + }); + }); + } else { + const value = transactionData.result.value || '0'; + // Handle bigint conversion properly + let bigIntValue: bigint; + if (typeof value === 'bigint') { + // If value is already a native bigint, use it directly + bigIntValue = value; + } else if (value) { + // If value exists but is not a bigint, convert it + bigIntValue = BigNumber.from(value).toBigInt(); + } else { + // If value is undefined/null, use 0 + bigIntValue = BigInt(0); + } + + const integerValue = formatEther(bigIntValue); + // Single transaction + addToBatch({ + title: 'Gas Tank Top-up', + description: `Add ${amount} ${selectedToken.symbol} to Gas Tank`, + to: transactionData.result.to, + value: integerValue, + data: transactionData.result.data, + chainId: selectedToken.chainId, + }); + } + + // Show the send modal with the batched transactions + setShowBatchSendModal(true); + showSend(); + onSuccess?.(); + } catch (error) { + console.error('Error processing top-up:', error); + console.warn('Failed to process top-up. Please try again.'); + logExchangeError('Failed to process top-up', { "error": error }, { component: 'TopUpModal', action: 'failed_to_process_top_up' }); + setErrorMsg('Failed to process top-up. Please try again.'); + } finally { + setIsProcessing(false); + } + }; + + const handleCancelSwap = () => { + setShowSwapConfirmation(false); + setSwapDetails(null); + }; + + if (!isOpen) return null; + + // Token Selection Modal using Search component + if (showTokenSelection) { + return ( + + {}} // Not used in this context + chains={chains} // Show all supported chains + setChains={() => {}} // Not allowing chain changes in gas tank + walletPortfolioData={walletPortfolioData?.result?.data} + walletPortfolioLoading={!isWalletPortfolioDataSuccess && !walletPortfolioDataError} + walletPortfolioFetching={false} + walletPortfolioError={!!walletPortfolioDataError} + refetchWalletPortfolio={refetchWalletPortfolioData} + /> + + ); + } + + // Swap Confirmation Modal + if (showSwapConfirmation && swapDetails && selectedToken) { + return ( + + +
+ Confirm Swap + +
+ + + + + Swap Summary + + From: + {amount} {selectedToken.symbol} + + + To: + {formatTokenAmount(Number(swapDetails.receiveAmount))} USDC + + + On: + {getChainName(selectedToken.chainId)} + + + + + + This swap will be executed first, then the USDC will be added to your Gas Tank. + + + + + + Cancel + + + {isProcessing ? ( + <> + + Processing... + + ) : ( + 'Confirm Swap' + )} + + + + +
+
+ ); + } + + return ( + + + + Top up + + + + + Select Fee Tokens and Input Amount + + + + setShowTokenSelection(true)}> + {selectedToken ? ( + + + + + + + {selectedToken.symbol} + on {getChainName(selectedToken.chainId)} + + + + ) : ( + + Select Token + + + )} + + + + $ + { + handleAmountChange(e.target.value); + }} + /> + + + + + + {selectedToken && amount ? (Number(amount)/(parseFloat(selectedToken.usdValue) || 1)).toFixed(2) : '0.00' } {selectedToken?.symbol} + + + + + {errorMsg && ( + + ⚠️ + {errorMsg} + + )} + + + + Rate + 1 USD ≈ {selectedToken ? (1 / (parseFloat(selectedToken.usdValue)) || 1).toFixed(4) : '1.08'} {selectedToken?.symbol || 'USDC'} + + + + Price impact + + + 0.00% + + + + Gas fee + + + ≈ $0.05 + + + + + {(() => { + if (isProcessing) { + return ( + <> + + Processing... + + ); + } + if (selectedToken && amount) { + return `Top Up $${amount}`; + } + return 'Top Up'; + })()} + + + + + ); +}; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +`; + +const ModalContainer = styled.div` + background: #1a1a1a; + border-radius: 20px; + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + border: 1px solid #333; +`; + +const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px; + border-bottom: 1px solid #333; +`; + +const Title = styled.h2` + color: #ffffff; + font-size: 20px; + font-weight: 600; + margin: 0; +`; + +const CloseButton = styled.button` + background: rgba(255, 255, 255, 0.1); + color: white; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 16px; + transition: background-color 0.2s; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } +`; + +const Content = styled.div` + padding: 24px; +`; + +const Section = styled.div` + margin-bottom: 24px; +`; + +const Label = styled.label` + display: block; + color: #ffffff; + font-size: 14px; + font-weight: 600; + margin-bottom: 12px; +`; + +const LoadingContainer = styled.div` + display: flex; + align-items: center; + gap: 12px; + color: #9ca3af; + padding: 20px; + justify-content: center; +`; + +const ErrorMessage = styled.div` + color: #ef4444; + padding: 20px; + text-align: center; +`; + +const TokenList = styled.div` + max-height: 300px; + overflow-y: auto; + border: 1px solid #333; + border-radius: 12px; + + /* Custom scrollbar styles */ + scrollbar-width: thin; + scrollbar-color: transparent transparent; + transition: scrollbar-color 0.3s ease; + + &:hover { + scrollbar-color: #7c3aed #2a2a2a; + } + + /* WebKit scrollbar styles */ + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 4px; + transition: background 0.3s ease, opacity 0.3s ease; + } + + &:hover::-webkit-scrollbar-thumb { + background: #7c3aed; + } + + &::-webkit-scrollbar-thumb:hover { + background: #8b5cf6; + } + + /* Auto-hide behavior */ + &:not(:hover)::-webkit-scrollbar-thumb { + opacity: 0; + transition: opacity 0.5s ease 1s; + } +`; + +const TokenItem = styled.div<{ $isSelected: boolean }>` + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + cursor: pointer; + border-bottom: 1px solid #2a2a2a; + background: ${(props) => (props.$isSelected ? '#7c3aed20' : 'transparent')}; + border-left: ${(props) => (props.$isSelected ? '3px solid #7c3aed' : 'none')}; + transition: background-color 0.2s; + + &:hover { + background: #2a2a2a; + } + + &:last-child { + border-bottom: none; + } +`; + +const TokenInfo = styled.div` + display: flex; + align-items: center; + gap: 12px; +`; + +const TokenLogo = styled.img` + width: 32px; + height: 32px; + border-radius: 50%; +`; + +const TokenDetails = styled.div` + display: flex; + flex-direction: column; +`; + +const TokenSymbol = styled.div` + color: #ffffff; + font-weight: 600; + font-size: 16px; +`; + +const TokenName = styled.div` + color: #9ca3af; + font-size: 12px; +`; + +const ChainName = styled.div` + color: #8b5cf6; + font-size: 11px; + font-weight: 500; +`; + +const TokenBalance = styled.div` + color: #ffffff; + font-weight: 600; +`; + +const TokenBalanceContainer = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +`; + +const TokenBalanceUSD = styled.div` + color: #9ca3af; + font-size: 12px; + font-weight: 400; +`; + +const AmountContainer = styled.div` + display: flex; + align-items: center; + gap: 12px; +`; + +const UsdPriceDisplay = styled.div` + background: #2a2a2a; + border: 1px solid #444; + border-radius: 8px; + padding: 12px 16px; + color: #9ca3af; + font-size: 16px; + white-space: nowrap; + min-width: 80px; + text-align: right; +`; + +const MaxButton = styled.button` + background: #7c3aed; + color: white; + border: none; + border-radius: 6px; + padding: 8px 16px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background: #6d28d9; + } +`; + +const BalanceInfo = styled.div` + color: #9ca3af; + font-size: 12px; + margin-top: 8px; +`; + +const TopUpButton = styled.button` + width: 100%; + background: linear-gradient(135deg, #7c3aed, #a855f7); + color: white; + border: none; + border-radius: 12px; + padding: 16px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(124, 58, 237, 0.4); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } +`; + +const SwapConfirmationContainer = styled.div` + display: flex; + flex-direction: column; + gap: 24px; +`; + +const SwapDetailsSection = styled.div` + background: #2a2a2a; + border-radius: 12px; + padding: 20px; + border: 1px solid #444; +`; + +const SwapTitle = styled.h3` + color: #ffffff; + font-size: 18px; + font-weight: 600; + margin: 0 0 16px 0; +`; + +const SwapDetail = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + + &:last-child { + margin-bottom: 0; + } +`; + +const SwapLabel = styled.div` + color: #9ca3af; + font-size: 14px; +`; + +const SwapValue = styled.div` + color: #ffffff; + font-size: 14px; + font-weight: 600; +`; + +const WarningBox = styled.div` + background: rgba(251, 191, 36, 0.1); + border: 1px solid rgba(251, 191, 36, 0.3); + border-radius: 8px; + padding: 16px; +`; + +// const WarningText = styled.p` +// color: #fbbf24; +// font-size: 14px; +// margin: 0; +// line-height: 1.5; +// `; + +const ButtonContainer = styled.div` + display: flex; + gap: 12px; +`; + +const CancelButton = styled.button` + flex: 1; + background: transparent; + color: #9ca3af; + border: 1px solid #444; + border-radius: 12px; + padding: 16px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: #2a2a2a; + color: #ffffff; + border-color: #666; + } +`; + +const ConfirmButton = styled.button` + flex: 1; + background: linear-gradient(135deg, #7c3aed, #a855f7); + color: white; + border: none; + border-radius: 12px; + padding: 16px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(124, 58, 237, 0.4); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } +`; + +// New Modal Styles +const NewModalContainer = styled.div` + background: #1a1a1a; + border-radius: 20px; + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + border: 1px solid #333; +`; + +const NewHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px; + border-bottom: 1px solid #333; +`; + +const NewTitle = styled.h2` + color: #ffffff; + font-size: 24px; + font-weight: 600; + margin: 0; +`; + +const NewContent = styled.div` + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; +`; + +const SectionDescription = styled.div` + color: #9ca3af; + font-size: 16px; + margin-bottom: 4px; +`; + +const TokenAmountContainer = styled.div` + background: #000000; + border: 1px solid #444; + border-radius: 12px; + padding: 0; + overflow: hidden; + transition: border-color 0.2s; + + &:hover { + border-color: #7c3aed; + } +`; + +const MainRow = styled.div` + padding: 16px; + display: flex; + justify-content: flex-start; + align-items: center; + position: relative; +`; + +const SelectTokenButton = styled.button` + background: rgba(139, 92, 246, 0.1); + border: 1px solid rgba(139, 92, 246, 0.3); + border-radius: 50px; + cursor: pointer; + display: flex; + align-items: center; + padding: 8px 16px; + outline: none; + margin-right: 16px; + transition: all 0.2s ease; + + &:hover { + background: rgba(139, 92, 246, 0.2); + border-color: rgba(139, 92, 246, 0.5); + } + + &:focus { + outline: none; + } +`; + +const SelectedTokenDisplay = styled.div` + display: flex; + align-items: center; + gap: 12px; + flex: 1; +`; + +const SelectedTokenLogo = styled.img` + width: 32px; + height: 32px; + border-radius: 50%; +`; + +const TokenLogoContainer = styled.div` + position: relative; + display: inline-block; +`; + +const ChainLogoOverlay = styled.img` + position: absolute; + bottom: 0; + right: 0; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid #000000; +`; + +const SelectedTokenDetails = styled.div` + display: flex; + flex-direction: column; +`; + +const SelectedTokenSymbol = styled.div` + color: #ffffff; + font-weight: 600; + font-size: 16px; +`; + +const SelectedTokenChain = styled.div` + color: #8b5cf6; + font-size: 12px; +`; + +const SelectTokenPlaceholder = styled.div` + color: #9ca3af; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + font-size: 16px; +`; + +const DropdownArrow = styled.span` + color: #9ca3af; + font-size: 12px; +`; + +const AmountInputGroup = styled.div` + display: flex; + align-items: center; + position: absolute; + right: 16px; +`; + +const DollarSymbol = styled.span` + color: #ffffff; + font-size: 48px; + font-weight: 700; + margin-right: -2px; +`; + +const AmountInput = styled.input` + background: transparent; + border: none; + color: #ffffff; + font-size: 48px; + font-weight: 700; + outline: none; + width: 9rem; + text-align: right; + padding: 0; + margin: 0; + margin-left: -2px; + + &::placeholder { + color: #6b7280; + font-weight: 700; + text-align: right; + } + + &:focus { + outline: none; + } +`; + +const TokenAmountRow = styled.div` + padding: 16px; + display: flex; + justify-content: flex-end; + align-items: center; +`; + +const TokenAmountDisplay = styled.div` + color: #9ca3af; + font-size: 14px; +`; + + +const WarningContainer = styled.div` + background: rgba(251, 191, 36, 0.1); + border: 1px solid rgba(251, 191, 36, 0.3); + border-radius: 8px; + padding: 12px; + display: flex; + align-items: center; + gap: 8px; +`; + +const WarningIcon = styled.span` + font-size: 16px; +`; + +const WarningText = styled.div` + color: #fbbf24; + font-size: 14px; +`; + +const DetailsSection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const DetailRow = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const DetailLabel = styled.div` + color: #9ca3af; + font-size: 14px; + display: flex; + align-items: center; + gap: 4px; +`; + +const DetailValue = styled.div` + color: #ffffff; + font-size: 14px; + font-weight: 500; +`; + +const InfoIcon = styled.span` + color: #6b7280; + font-size: 12px; +`; + +const NewTopUpButton = styled.button` + width: 100%; + background: linear-gradient(135deg, #7c3aed, #a855f7); + color: white; + border: none; + border-radius: 12px; + padding: 16px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 8px; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(124, 58, 237, 0.4); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } +`; + + +const SearchOverlay = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.9); + backdrop-filter: blur(4px); + z-index: 3000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +`; + +export default TopUpModal; diff --git a/src/apps/gas-tank/components/UniversalGasTank.styles.ts b/src/apps/gas-tank/components/UniversalGasTank.styles.ts new file mode 100644 index 00000000..6e9695f6 --- /dev/null +++ b/src/apps/gas-tank/components/UniversalGasTank.styles.ts @@ -0,0 +1,365 @@ +import styled, { keyframes, css } from 'styled-components'; +import { darken } from 'polished'; +import { colors, typography } from './shared.styles'; + +const shimmer = keyframes` + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } +`; + +const skeletonAnimation = css` + background: linear-gradient( + 90deg, + ${colors.background} 0%, + rgba(139, 92, 246, 0.1) 50%, + ${colors.background} 100% + ); + background-size: 1000px 100%; + animation: ${shimmer} 2s infinite linear; + border-radius: 4px; +`; + + +export const S = { + MainContainer: styled.div` + background: #1A1B1E; + border-radius: 16px; + padding: 24px; + width: 100%; + max-width: 1400px; + color: #FFFFFF; + display: flex; + gap: 32px; + + @media (max-width: 768px) { + flex-direction: column; + max-width: 480px; + gap: 24px; + } + `, + + LeftSection: styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 24px; + min-width: 400px; + + @media (max-width: 768px) { + min-width: auto; + } + `, + + RightSection: styled.div` + flex: 1.5; + + @media (max-width: 768px) { + flex: 1; + } + `, + + Header: styled.div` + display: flex; + justify-content: flex-start; + align-items: center; + margin-bottom: 16px; + `, + + TitleSection: styled.div` + display: flex; + align-items: center; + gap: 12px; + `, + + Icon: styled.span` + font-size: 24px; + `, + + IconImage: styled.img` + width: 24px; + height: 24px; + object-fit: contain; + `, + + Title: styled.h2` + font-size: 20px; + font-weight: 600; + margin: 0; + color: #FFFFFF; + `, + + RefreshButton: styled.button` + background: rgba(139, 92, 246, 0.1); + color: ${colors.text.accent}; + border: 1px solid rgba(139, 92, 246, 0.3); + border-radius: 6px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; + + &:hover { + background: rgba(139, 92, 246, 0.2); + border-color: rgba(139, 92, 246, 0.5); + transform: rotate(90deg); + } + `, + + BalanceSection: styled.div` + margin-bottom: 24px; + display: flex; + align-items: baseline; + gap: 8px; + `, + + BalanceAmount: styled.div` + font-size: 48px; + font-weight: 600; + color: #FFFFFF; + `, + + LoadingBalance: styled.div` + ${skeletonAnimation} + display: flex; + align-items: center; + gap: 12px; + color: #9ca3af; + padding: 20px; + justify-content: center; + height: 48px; + width: 180px; + `, + + LoadingContainer: styled.div` + display: flex; + gap: 32px; + width: 100%; + + @media (max-width: 768px) { + flex-direction: column; + gap: 24px; + } + `, + + LoadingLeftSection: styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 24px; + min-width: 400px; + + @media (max-width: 768px) { + min-width: auto; + } + `, + + LoadingRightSection: styled.div` + flex: 1.5; + display: flex; + flex-direction: column; + gap: 16px; + + @media (max-width: 768px) { + flex: 1; + } + `, + + LoadingHeader: styled.div` + ${skeletonAnimation} + height: 24px; + width: 200px; + margin-bottom: 16px; + `, + + LoadingBalanceAmount: styled.div` + ${skeletonAnimation} + height: 40px; + width: 150px; + margin-bottom: 8px; + `, + + LoadingNetworkLabel: styled.div` + ${skeletonAnimation} + height: 16px; + width: 120px; + margin-bottom: 24px; + `, + + LoadingButton: styled.div` + ${skeletonAnimation} + height: 44px; + width: 100%; + border-radius: 12px; + margin-bottom: 16px; + `, + + LoadingDescription: styled.div` + ${skeletonAnimation} + height: 16px; + width: 100%; + margin-bottom: 16px; + `, + + LoadingSpendInfo: styled.div` + display: flex; + align-items: center; + gap: 8px; + margin: 16px 0; + `, + + LoadingSpendLabel: styled.div` + ${skeletonAnimation} + height: 16px; + width: 80px; + `, + + LoadingSpendAmount: styled.div` + ${skeletonAnimation} + height: 16px; + width: 60px; + `, + + LoadingDetailedDescription: styled.div` + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 16px; + `, + + LoadingDetailedLine: styled.div` + ${skeletonAnimation} + height: 14px; + width: 100%; + + &:last-child { + width: 70%; + } + `, + + LoadingHistoryHeader: styled.div` + ${skeletonAnimation} + height: 20px; + width: 180px; + margin-bottom: 16px; + `, + + LoadingTableHeader: styled.div` + display: grid; + grid-template-columns: 0.5fr 1.5fr 1fr 1fr 1.5fr; + gap: 16px; + padding: 12px 16px; + border-bottom: 1px solid ${colors.border}; + margin-bottom: 12px; + `, + + LoadingTableHeaderCell: styled.div` + ${skeletonAnimation} + height: 14px; + width: 80%; + `, + + LoadingTableRow: styled.div` + display: grid; + grid-template-columns: 0.5fr 1.5fr 1fr 1fr 1.5fr; + gap: 16px; + padding: 12px 16px; + margin-bottom: 12px; + `, + + LoadingTableCell: styled.div` + ${skeletonAnimation} + height: 16px; + width: 90%; + + &:first-child { + width: 30px; + } + + &:last-child { + width: 100%; + } + `, + + ErrorBalance: styled.div` + ${typography.body}; + color: ${colors.status.error}; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + `, + + RetryButton: styled.button` + background: ${colors.status.error}; + color: ${colors.text.primary}; + border: none; + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + cursor: pointer; + + &:hover { + background: #dc2626; + } + `, + + NetworkLabel: styled.div` + color: #3B82F6; + font-size: 12px; + `, + + TopUpButton: styled.button` + background: #6D28D9; + color: #FFFFFF; + border: none; + border-radius: 12px; + padding: 12px 24px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + width: fit-content; + min-width: 200px; + + &:hover { + background: ${darken(0.1, '#6D28D9')}; + } + `, + + Description: styled.p` + ${typography.body}; + margin: 0 0 16px 0; + `, + + SpendInfo: styled.div` + display: flex; + align-items: center; + gap: 8px; + margin: 16px 0; + `, + + SpendLabel: styled.span` + color: #22C55E; + font-size: 14px; + `, + + SpendAmount: styled.span` + color: #FFFFFF; + font-size: 14px; + font-weight: 600; + `, + + DetailedDescription: styled.p` + color: #A1A1AA; + font-size: 14px; + line-height: 1.6; + margin: 0; + ` +}; \ No newline at end of file diff --git a/src/apps/gas-tank/components/UniversalGasTank.tsx b/src/apps/gas-tank/components/UniversalGasTank.tsx new file mode 100644 index 00000000..41d0dc5b --- /dev/null +++ b/src/apps/gas-tank/components/UniversalGasTank.tsx @@ -0,0 +1,175 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { useState } from 'react'; +import { CircularProgress } from '@mui/material'; +import { useWalletAddress } from '@etherspot/transaction-kit'; +import { S } from './UniversalGasTank.styles'; + +// components +import TopUpModal from './TopUpModal'; + +// hooks +import useGasTankBalance from '../hooks/useGasTankBalance'; +import GasTankHistory, { useGasTankHistory } from './GasTankHistory'; + +// assets +import gasTankIcon from '../assets/gas-tank-icon.png'; + +const UniversalGasTank = () => { + const walletAddress = useWalletAddress(); + const [showTopUpModal, setShowTopUpModal] = useState(false); + + const { + totalBalance, + isLoading: isBalanceLoading, + error: balanceError, + refetch, + } = useGasTankBalance({ pauseAutoRefresh: showTopUpModal }); + + // Use the custom hook to get totalSpend from history + const { + historyData, + totalSpend = 0, + loading: isHistoryLoading, + error: historyError, + refetch: refetchHistory, + } = useGasTankHistory(walletAddress, { pauseAutoRefresh: showTopUpModal }); + + const handleTopUp = () => { + setShowTopUpModal(true); + }; + + // Show skeleton loading if either balance or history is loading + if (isBalanceLoading || isHistoryLoading) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {Array.from({ length: 8 }).map((_, index) => ( + + + + + + + + ))} + + + + setShowTopUpModal(false)} + onSuccess={() => { + setShowTopUpModal(false); + refetch(); + refetchHistory(); + }} + /> + + ); + } + + return ( + + + + + + Universal Gas Tank + + + + + {balanceError ? ( + + Error loading balance + Retry + + ) : ( + <> + ${totalBalance.toFixed(2)} + On All Networks + + )} + + + Top up + + + Top up your Gas Tank so you pay for network fees on every chain. + + + + Total Spend + + {historyError ? '$0.00' : `$${totalSpend.toFixed(2)}`} + + + + + The PillarX Gas Tank is your universal balance for covering transaction + fees across all networks. When you top up your Tank, you're + allocating tokens specifically for paying gas. You can increase your + balance anytime, and the tokens in your Tank can be used to pay network + fees on any supported chain. + + + + + + + + setShowTopUpModal(false)} + onSuccess={() => { + setShowTopUpModal(false); + refetch(); + refetchHistory(); + }} + /> + + ); +}; + +export default UniversalGasTank; diff --git a/src/apps/gas-tank/components/shared.styles.ts b/src/apps/gas-tank/components/shared.styles.ts new file mode 100644 index 00000000..81e07ca9 --- /dev/null +++ b/src/apps/gas-tank/components/shared.styles.ts @@ -0,0 +1,61 @@ +import styled from 'styled-components'; + +// Shared colors +export const colors = { + background: '#1a1a1a', + border: '#333', + text: { + primary: '#ffffff', + secondary: '#9ca3af', + accent: '#8b5cf6', + }, + status: { + success: '#4ade80', + error: '#ef4444', + }, + button: { + primary: '#7c3aed', + primaryHover: '#6d28d9', + }, +}; + +// Shared typography +export const typography = { + title: ` + font-size: 18px; + font-weight: 600; + color: ${colors.text.primary}; + `, + body: ` + font-size: 14px; + line-height: 1.5; + color: ${colors.text.primary}; + `, + small: ` + font-size: 12px; + line-height: 1.5; + color: ${colors.text.secondary}; + `, +}; + +// Shared components +export const BaseContainer = styled.div` + background: ${colors.background}; + border: 1px solid ${colors.border}; +`; + +export const BaseHeader = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 24px; +`; + +export const BaseTitle = styled.h2` + ${typography.title}; + margin: 0; +`; + +export const IconWrapper = styled.span` + font-size: 18px; +`; \ No newline at end of file diff --git a/src/apps/gas-tank/constants/tokens.ts b/src/apps/gas-tank/constants/tokens.ts new file mode 100644 index 00000000..b27fa84e --- /dev/null +++ b/src/apps/gas-tank/constants/tokens.ts @@ -0,0 +1,15 @@ +import { isGnosisEnabled } from '../../../utils/blockchain'; + +const allStableCurrencies = [ + { chainId: 1, address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' }, + { chainId: 10, address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85' }, // USDC on Optimism + { chainId: 137, address: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359' }, // USDC on Polygon + { chainId: 8453, address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' }, // USDC on Base + { chainId: 42161, address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' }, // USDC on Arbitrum + { chainId: 56, address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d' }, // USDC on BNB Smart Chain + { chainId: 100, address: '0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0' }, // USDC on Gnosis +]; + +export const STABLE_CURRENCIES = allStableCurrencies.filter( + (currency) => isGnosisEnabled || currency.chainId !== 100 +); diff --git a/src/apps/gas-tank/hooks/useGasTankBalance.tsx b/src/apps/gas-tank/hooks/useGasTankBalance.tsx new file mode 100644 index 00000000..8d0046b0 --- /dev/null +++ b/src/apps/gas-tank/hooks/useGasTankBalance.tsx @@ -0,0 +1,123 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useWalletAddress } from '@etherspot/transaction-kit'; +import { logExchangeError } from '../utils/sentry'; + +interface ChainBalance { + chainId: string; + balance: string; +} + +interface GasTankBalanceResponse { + balance: { + [chainId: string]: ChainBalance; + }; +} + +interface UseGasTankBalanceReturn { + totalBalance: number; + chainBalances: ChainBalance[]; + isLoading: boolean; + error: string | null; + refetch: () => void; +} + +interface UseGasTankBalanceOptions { + pauseAutoRefresh?: boolean; +} + +const useGasTankBalance = (options: UseGasTankBalanceOptions = {}): UseGasTankBalanceReturn => { + const { pauseAutoRefresh = false } = options; + const walletAddress = useWalletAddress(); + const [totalBalance, setTotalBalance] = useState(0); + const [chainBalances, setChainBalances] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const paymasterUrl = import.meta.env.VITE_PAYMASTER_URL; + + const fetchGasTankBalance = useCallback(async () => { + if (!walletAddress) { + setError(null); + setTotalBalance(0); + setChainBalances([]); + return; + } + + if (!paymasterUrl) { + setError('Paymaster URL is not configured'); + setTotalBalance(0); + setChainBalances([]); + return; + } + setIsLoading(true); + setError(null); + + try { + const response = await fetch( + `${paymasterUrl}/getGasTankBalance?sender=${walletAddress}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch gas tank balance: ${response.status}`); + } + + const data: GasTankBalanceResponse = await response.json(); + + // Extract chain balances + const balances: ChainBalance[] = Object.values(data.balance || {}); + setChainBalances(balances); + + // Calculate total balance by summing all chain balances + const total = balances.reduce((sum, chainBalance) => { + const balance = parseFloat(chainBalance.balance) || 0; + return sum + balance; + }, 0); + + setTotalBalance(total); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Unknown error occurred'; + setError(errorMessage); + logExchangeError(errorMessage, { "error": err }, { component: 'useGasTankBalance', action: 'failed_to_fetch_gas_tank_balance' }); + console.error('Error fetching gas tank balance:', err); + + // Set default values on error + setTotalBalance(0); + setChainBalances([]); + } finally { + setIsLoading(false); + } + }, [walletAddress, paymasterUrl]); + + // Initial fetch and when wallet address changes + useEffect(() => { + fetchGasTankBalance(); + }, [fetchGasTankBalance]); + + // Auto-refresh disabled + // useEffect(() => { + // if (!walletAddress || pauseAutoRefresh) return; + + // const interval = setInterval(() => { + // fetchGasTankBalance(); + // }, 30000); // 30 seconds + + // // Cleanup interval on component unmount + // return () => clearInterval(interval); + // }, [walletAddress, fetchGasTankBalance, pauseAutoRefresh]); + + return { + totalBalance, + chainBalances, + isLoading, + error, + refetch: fetchGasTankBalance, + }; +}; + +export default useGasTankBalance; diff --git a/src/apps/gas-tank/hooks/useOffer.tsx b/src/apps/gas-tank/hooks/useOffer.tsx new file mode 100644 index 00000000..d0c16c60 --- /dev/null +++ b/src/apps/gas-tank/hooks/useOffer.tsx @@ -0,0 +1,504 @@ +import { + useEtherspotUtils, + useWalletAddress, +} from '@etherspot/transaction-kit'; +import { + LiFiStep, + Route, + RoutesRequest, + getRoutes, + getStepTransaction, +} from '@lifi/sdk'; +import { + createPublicClient, + encodeFunctionData, + erc20Abi, + formatUnits, + http, + parseUnits, + zeroAddress, +} from 'viem'; + +// types +import { StepTransaction, SwapOffer, SwapType } from '../utils/types'; + +// utils +import { + Token, +} from '../../../services/tokensData'; +import { getNetworkViem } from '../../deposit/utils/blockchain'; +import { + getNativeBalanceFromPortfolio, + processEth, + toWei, +} from '../utils/blockchain'; +import { + getWrappedTokenAddressIfNative, + isNativeToken, + isWrappedToken, +} from '../utils/wrappedTokens'; +import { + addExchangeBreadcrumb, + logExchangeError, + startExchangeTransaction, +} from '../utils/sentry'; + +export const USDC_ADDRESSES: { [chainId: number]: string } = { + 137: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', // Polygon + 42161: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // Arbitrum + 10: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism + // Add more chains as needed +}; + +const useOffer = () => { + const { isZeroAddress } = useEtherspotUtils(); + const walletAddress = useWalletAddress(); + + /** + * Get native fee estimation for ERC20 tokens + * This function calculates how much native token (ETH, MATIC, etc.) is needed + * to pay for gas fees when swapping ERC20 tokens + */ + const getNativeFeeForERC20 = async ({ + tokenAddress, + chainId, + feeAmount, + slippage = 0.03, + }: { + tokenAddress: string; + chainId: number; + feeAmount: string; + slippage?: number; + }) => { + + try { + + /** + * Create route request to find the best path for converting + * the ERC20 token to native token for fee payment + */ + const feeRouteRequest: RoutesRequest = { + fromChainId: chainId, + toChainId: chainId, + fromTokenAddress: tokenAddress, + toTokenAddress: zeroAddress, + fromAmount: feeAmount, + options: { + slippage, + bridges: { + allow: ['relay'], + }, + exchanges: { allow: ['openocean', 'kyberswap'] }, + }, + }; + + const result = await getRoutes(feeRouteRequest); + const { routes } = result; + + const allOffers = routes as Route[]; + + if (allOffers.length) { + /** + * Find the best offer by comparing receive amounts + * The best offer is the one that gives the most native tokens + */ + const bestOffer = allOffers.reduce((a, b) => { + const receiveAmountA = processEth(a.toAmount, 18); + const receiveAmountB = processEth(b.toAmount, 18); + return receiveAmountA > receiveAmountB ? a : b; + }); + + return bestOffer; + } + + return undefined; + } catch (e) { + console.error('Failed to get native fee estimation via LiFi:', e); + logExchangeError('Failed to get native fee estimation via LiFi', { "error": e }, { component: 'useOffer', action: 'failed_to_get_native_fee_estimation' }); + return undefined; + } + }; + + /** + * Get the best swap offer for a given token pair + * This function finds the optimal route for swapping one token to another + * across different exchanges and bridges + */ + const getBestOffer = async ({ + fromAmount, + fromTokenAddress, + fromChainId, + fromTokenDecimals, + slippage = 0.03, + }: SwapType): Promise => { + try { + /** + * Step 1: Handle wrapped token conversion + * Replace native token addresses with their wrapped equivalents + * This is required for some DEX aggregators + */ + const fromTokenAddressWithWrappedCheck = getWrappedTokenAddressIfNative( + fromTokenAddress, + fromChainId + ); + const toTokenAddress = USDC_ADDRESSES[fromChainId]; + const toTokenDecimals = 6; // USDC has 6 decimals + + /** + * Step 2: Create route request for LiFi + * This request includes all necessary parameters for finding swap routes + */ + const routesRequest: RoutesRequest = { + fromChainId, + toChainId: fromChainId, // Swapping within the same chain + fromTokenAddress: fromTokenAddressWithWrappedCheck, + toTokenAddress, + fromAmount: parseUnits(String(fromAmount), fromTokenDecimals).toString(), + options: { + slippage, + bridges: { + allow: ['relay'], + }, + exchanges: { allow: ['openocean', 'kyberswap'] }, + }, + }; + + const result = await getRoutes(routesRequest); + const { routes } = result; + + const allOffers = routes as Route[]; + + if (allOffers.length) { + /** + * Step 4: Find the best offer + * Compare all available routes and select the one with the highest output + */ + const bestOffer = allOffers.reduce((a, b) => { + const receiveAmountA = processEth(a.toAmount, toTokenDecimals); + const receiveAmountB = processEth(b.toAmount, toTokenDecimals); + return receiveAmountA > receiveAmountB ? a : b; + }); + + const selectedOffer: SwapOffer = { + tokenAmountToReceive: processEth(bestOffer.toAmount, toTokenDecimals), + offer: bestOffer as Route, + }; + + return selectedOffer; + } + + // Return undefined instead of empty object when no routes found + return undefined; + } catch (e) { + console.error( + 'Sorry, an error occurred while trying to fetch the best swap offer. Please try again.', + e + ); + logExchangeError('Failed to get best swap offer via LiFi', { "error": e }, { component: 'useOffer', action: 'failed_to_get_best_swap_offer' }); + // Return undefined instead of empty object on error + return undefined; + } + }; + + /** + * Check if token allowance is set for a specific spender + * This function verifies if the wallet has approved a contract to spend tokens + */ + const isAllowanceSet = async ({ + owner, + spender, + tokenAddress, + chainId, + }: { + owner: string; + spender: string; + tokenAddress: string; + chainId: number; + }) => { + if (isZeroAddress(tokenAddress)) return undefined; + + // Validate inputs + if (!owner || !spender || !tokenAddress) { + console.warn('Invalid inputs for allowance check:', { + owner, + spender, + tokenAddress, + }); + return undefined; + } + + try { + const publicClient = createPublicClient({ + chain: getNetworkViem(chainId), + transport: http(), + }); + + const allowance = await publicClient.readContract({ + address: tokenAddress as `0x${string}`, + abi: erc20Abi, + functionName: 'allowance', + args: [owner as `0x${string}`, spender as `0x${string}`], + }); + + return allowance === BigInt(0) ? undefined : allowance; + } catch (error) { + console.error('Failed to check token allowance:', error); + logExchangeError('Failed to check token allowance', { "error": error }, { component: 'useOffer', action: 'failed_to_check_token_allowance' }); + return undefined; + } + }; + + /** + * Build step transactions for a swap + * This function creates the sequence of transactions needed to execute a swap + * including fee payments, approvals, and the actual swap + */ + const getStepTransactions = async ( + route: Route, + fromAccount: string, + userPortfolio: Token[] | undefined, + fromAmount: number // Pass the original user input amount + ): Promise => { + const stepTransactions: StepTransaction[] = []; + const fromTokenChainId = route.fromToken.chainId; + /** + * Step 1: Determine if wrapping is required + * Check if we need to wrap native tokens before swapping + */ + const isWrapRequired = isWrappedToken( + route.fromToken.address, + route.fromToken.chainId + ); + + // Convert fromAmount (number) to BigInt using the correct token decimals + const decimals = typeof route.fromToken.decimals === 'number' && route.fromToken.decimals > 0 + ? route.fromToken.decimals + : 18; // fallback to 18 if undefined or invalid + const fromAmountBigInt = parseUnits( + String(fromAmount), + decimals + ); + + /** + * Step 2: Fee calculation and validation + * Calculate 1% platform fee and validate fee receiver address + */ + const feeReceiver = import.meta.env.VITE_SWAP_FEE_RECEIVER; + + // Validate fee receiver address + if (!feeReceiver) { + logExchangeError('Fee receiver address is not configured', { "error": 'Fee receiver address is not configured' }, { component: 'useOffer', action: 'fee_receiver_address_not_configured' }); + throw new Error('Fee receiver address is not configured'); + } + + /** + * Step 3: Balance checks + * Verify user has sufficient balance for swap and fees + */ + let userNativeBalance = BigInt(0); + try { + // Get native balance from portfolio + const nativeBalance = + getNativeBalanceFromPortfolio(userPortfolio, fromTokenChainId) || '0'; + userNativeBalance = toWei(nativeBalance, 18); + } catch (e) { + logExchangeError('Failed to fetch balances for swap', { "error": e }, { component: 'useOffer', action: 'failed_to_fetch_balances_for_swap' }); + throw new Error('Unable to fetch balances for swap.'); + } + + console.log('totalNativeRequired', fromAmountBigInt); + // // Calculate total required + // const totalNativeRequired = fromAmountBigInt; + // if (userNativeBalance < totalNativeRequired) { + // throw new Error( + // 'Insufficient native token balance to cover swap and fee.' + // ); + // } + + /** + * Step 4: Wrap transaction (if required) + * If the route requires wrapped tokens but user has native tokens, + * add a wrapping transaction + */ + if (isWrapRequired) { + const wrapCalldata = encodeFunctionData({ + abi: [ + { + name: 'deposit', + type: 'function', + stateMutability: 'payable', + inputs: [], + outputs: [], + }, + ], + functionName: 'deposit', + }); + + stepTransactions.push({ + to: route.fromToken.address, // wrapped token address + data: wrapCalldata, + value: BigInt(route.fromAmount), + chainId: route.fromChainId, + }); + } + + /** + * Step 5: Process route steps + * Handle each step in the swap route, including approvals and swaps + */ + try { + // eslint-disable-next-line no-restricted-syntax + for (const step of route.steps) { + // --- APPROVAL LOGIC --- + // Only require approval for ERC20 tokens (never for native tokens, including special addresses like POL/MATIC) + const isTokenNative = isNativeToken(step.action.fromToken.address); + + // Validate required addresses before proceeding + if (!step.action.fromToken.address) { + throw new Error('Token address is undefined in step'); + } + + const isAllowance = isTokenNative + ? undefined // Native tokens never require approval + : // eslint-disable-next-line no-await-in-loop + await isAllowanceSet({ + owner: fromAccount, + spender: step.estimate.approvalAddress || '', + tokenAddress: step.action.fromToken.address, + chainId: step.action.fromChainId, + }); + + const isEnoughAllowance = isAllowance + ? formatUnits(isAllowance, step.action.fromToken.decimals) >= + formatUnits( + BigInt(step.action.fromAmount), + step.action.fromToken.decimals + ) + : undefined; + + // Here we are checking if this is not a native/gas token and if the allowance + // is not set, then we manually add an approve transaction + if (!isTokenNative && !isEnoughAllowance) { + // Validate approval address before using it + if (!step.estimate.approvalAddress) { + throw new Error( + 'Approval address is undefined for non-native token' + ); + } + + // We encode the callData for the approve transaction + const calldata = encodeFunctionData({ + abi: [ + { + inputs: [ + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + ], + name: 'approve', + outputs: [ + { + internalType: 'bool', + name: '', + type: 'bool', + }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + functionName: 'approve', + args: [ + step.estimate.approvalAddress as `0x${string}`, + BigInt(step.estimate.fromAmount), + ], + }); + + // We push the approve transaction to the stepTransactions array + stepTransactions.push({ + data: calldata, + value: BigInt(0), + to: step.action.fromToken.address, + chainId: step.action.fromChainId, + transactionType: 'approval', + }); + } + + const actionCopy = { ...step.action }; + + // This is to make sure we have a fromAddress, which is not always + // provided by Lifi + if (!actionCopy.fromAddress) { + actionCopy.fromAddress = fromAccount; + } + + // This is to make sure we have a toAddress, which is not always + // provided by Lifi, from and to address are the same + actionCopy.toAddress = actionCopy.fromAddress; + + const modifiedStep: LiFiStep = { ...step, action: actionCopy }; + + // eslint-disable-next-line no-await-in-loop + const updatedStep = await getStepTransaction(modifiedStep); + + if (!updatedStep.transactionRequest) { + throw new Error('No transactionRequest'); + } + + const { to, data, value, gasLimit, gasPrice, chainId, type } = + updatedStep.transactionRequest; + + // Validate the 'to' address before adding to stepTransactions + if (!to) { + throw new Error('Transaction "to" address is undefined'); + } + + // Handle bigint conversions properly for values from LiFi SDK + const valueBigInt = + typeof value === 'bigint' ? value : BigInt(String(value || 0)); + const gasLimitBigInt = + typeof gasLimit === 'bigint' + ? gasLimit + : BigInt(String(gasLimit || 0)); + const gasPriceBigInt = + typeof gasPrice === 'bigint' + ? gasPrice + : BigInt(String(gasPrice || 0)); + + stepTransactions.push({ + to, + data: data as `0x${string}`, + value: valueBigInt, + gasLimit: gasLimitBigInt, + gasPrice: gasPriceBigInt, + chainId, + type, + }); + } + } catch (error) { + logExchangeError( + 'Failed to get step transactions:', + { "error": error }, + { component: 'useOffer', action: 'failed_to_get_step_transactions' } + ); + console.error('Failed to get step transactions:', error); + throw error; // Re-throw so the UI can handle it + } + + return stepTransactions; + }; + + return { + getBestOffer, + getStepTransactions, + }; +}; + +export default useOffer; diff --git a/src/apps/gas-tank/hooks/useReducerHooks.tsx b/src/apps/gas-tank/hooks/useReducerHooks.tsx new file mode 100644 index 00000000..ec4857d0 --- /dev/null +++ b/src/apps/gas-tank/hooks/useReducerHooks.tsx @@ -0,0 +1,6 @@ +import { useDispatch, useSelector } from 'react-redux'; +import type { AppDispatch, RootState } from '../../../store'; + +// To use throughout the app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/src/apps/gas-tank/hooks/useTokenSearch.ts b/src/apps/gas-tank/hooks/useTokenSearch.ts new file mode 100644 index 00000000..d0b6ca9b --- /dev/null +++ b/src/apps/gas-tank/hooks/useTokenSearch.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; +import { useGetSearchTokensQuery } from '../../../services/pillarXApiSearchTokens'; +import { MobulaChainNames, getChainId } from '../utils/constants'; + +export function useTokenSearch(props: { + isBuy: boolean; + chains: MobulaChainNames; +}) { + const [searchText, setSearchText] = useState(''); + const [debouncedSearchText, setDebouncedSearchText] = useState(''); + + const { + data: searchData, + isLoading: isSearchLoading, + isFetching, + } = useGetSearchTokensQuery( + { + searchInput: debouncedSearchText, + filterBlockchains: getChainId(props.chains), + }, + { + skip: !debouncedSearchText, + refetchOnFocus: false, + } + ); + + return { + searchText, + setSearchText, + searchData, + isSearchLoading, + isFetching, + }; +} diff --git a/src/apps/gas-tank/icon.png b/src/apps/gas-tank/icon.png new file mode 100644 index 00000000..e86f5f68 Binary files /dev/null and b/src/apps/gas-tank/icon.png differ diff --git a/src/apps/gas-tank/index.tsx b/src/apps/gas-tank/index.tsx new file mode 100644 index 00000000..73177dce --- /dev/null +++ b/src/apps/gas-tank/index.tsx @@ -0,0 +1,255 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { useEtherspot, useWalletAddress } from '@etherspot/transaction-kit'; +import styled from 'styled-components'; +import { useEffect, useRef } from 'react'; +import { createConfig, EVM } from '@lifi/sdk'; + +// styles +import './styles/gasTank.css'; + +// components +import GasTank from './components/GasTank'; + +// utils +import { supportedChains } from '../../utils/blockchain'; +import { addExchangeBreadcrumb, initSentryForGasTank, logExchangeEvent } from './utils/sentry'; + +export const App = () => { + const { provider } = useEtherspot(); + const walletAddress = useWalletAddress(); + + // Use ref to track if config has been initialized + const configInitialized = useRef(false); + useEffect(() => { + initSentryForGasTank(); + + // Log app initialization + logExchangeEvent( + 'Gas Tank app initialized', + 'info', + { + walletAddress, + }, + { + component: 'App', + action: 'initialization', + } + ); + + addExchangeBreadcrumb('Gas Tank app loaded', 'app', { + walletAddress, + timestamp: new Date().toISOString(), + }); + }, [walletAddress]); + + /** + * Initialize LiFi SDK configuration + * This sets up the LiFi SDK with the wallet provider and chain switching capabilities + * Only runs once when the provider is available to avoid multiple initializations + */ + useEffect(() => { + if (!provider || configInitialized.current) { + return; + } + + try { + /** + * Create LiFi configuration with: + * - Integrator name for tracking + * - EVM provider with wallet client and chain switching + * - API key for LiFi services + */ + createConfig({ + integrator: 'PillarX', + providers: [ + EVM({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getWalletClient: async () => provider as any, + + /** + * Chain switching functionality + * Handles switching between different blockchain networks + * Implements EIP-1193 standard for wallet chain switching + */ + switchChain: async (chainId) => { + // Log chain switching initiation + logExchangeEvent( + 'Chain switching initiated', + 'info', + { + walletAddress, + chainId, + currentChain: supportedChains.find( + (chain) => chain.id === chainId + ), + }, + { + component: 'App', + action: 'chain_switch', + } + ); + + try { + /** + * Step 1: Validate chain support + * Check if the requested chain is supported by our application + */ + const targetChain = supportedChains.find( + (chain) => chain.id === chainId + ); + + if (!targetChain) { + throw new Error(`Chain ${chainId} is not supported`); + } + + /** + * Step 2: Request EIP-1193 chain switch on the underlying provider + * This uses the standard Ethereum wallet interface to switch chains + */ + const providerWithRequest = provider as { + request?: (args: { + method: string; + params: unknown[]; + }) => Promise; + }; + + if (providerWithRequest.request) { + try { + /** + * Attempt to switch to the target chain + * Uses wallet_switchEthereumChain method + */ + await providerWithRequest.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: `0x${chainId.toString(16)}` }], + }); + } catch (switchError: unknown) { + /** + * Step 3: Handle chain not found (error code 4902) + * If the chain is not added to the wallet, try to add it + * This provides a seamless user experience + */ + if ((switchError as { code?: number }).code === 4902) { + await providerWithRequest.request({ + method: 'wallet_addEthereumChain', + params: [ + { + chainId: `0x${chainId.toString(16)}`, + chainName: targetChain.name, + nativeCurrency: targetChain.nativeCurrency, + rpcUrls: targetChain.rpcUrls.default.http, + blockExplorerUrls: targetChain.blockExplorers + ?.default?.url + ? [targetChain.blockExplorers.default.url] + : undefined, + }, + ], + }); + } else { + throw switchError; + } + } + } + + /** + * Step 4: Log successful chain switch + * Record the successful chain switch for monitoring and debugging + */ + logExchangeEvent( + 'Chain switching completed', + 'info', + { + walletAddress, + chainId, + newChain: targetChain, + }, + { + component: 'App', + action: 'chain_switch_success', + } + ); + + /** + * Step 5: Return the provider for LiFi SDK + * The LiFi SDK expects a specific client type, so we cast accordingly + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return provider as any; + } catch (error) { + /** + * Error handling for chain switching failures + * Log the error and re-throw for proper error handling + */ + logExchangeEvent( + 'Chain switching failed', + 'error', + { + walletAddress, + chainId, + error: + error instanceof Error ? error.message : String(error), + }, + { + component: 'App', + action: 'chain_switch_error', + } + ); + throw error; + } + }, + }), + ], + apiKey: import.meta.env.VITE_LIFI_API_KEY, + }); + + // Mark config as initialized to prevent re-initialization + configInitialized.current = true; + } catch (error) { + /** + * Error handling for LiFi config initialization + * Log the error and continue with app functionality + */ + console.error('Failed to initialize LiFi config:', error); + logExchangeEvent( + 'LiFi config initialization failed', + 'error', + { + walletAddress, + error: error instanceof Error ? error.message : String(error), + }, + { + component: 'App', + action: 'config_init_error', + } + ); + } + }, [provider, walletAddress]); + return ( + + + + ); +}; + +const Wrapper = styled.div` + display: flex; + width: 100%; + margin: 0 auto; + flex-direction: column; + max-width: 1248px; + padding: 32px; + + @media (min-width: 1024px) { + padding: 52px 62px; + } + + @media (max-width: 1024px) { + padding: 52px 32px; + } + + @media (max-width: 768px) { + padding: 32px 16px; + } +`; + +export default App; diff --git a/src/apps/gas-tank/manifest.json b/src/apps/gas-tank/manifest.json new file mode 100644 index 00000000..d98e4625 --- /dev/null +++ b/src/apps/gas-tank/manifest.json @@ -0,0 +1,9 @@ +{ + "title": "Gas Tank", + "description": "Universal Gas Tank for PillarX", + "translations": { + "en": { + "title": "Gas Tank by PillarX" + } + } +} diff --git a/src/apps/gas-tank/reducer/gasTankSlice.ts b/src/apps/gas-tank/reducer/gasTankSlice.ts new file mode 100644 index 00000000..9259d491 --- /dev/null +++ b/src/apps/gas-tank/reducer/gasTankSlice.ts @@ -0,0 +1,32 @@ +/* eslint-disable no-param-reassign */ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +// types +import { PortfolioData } from '../../../types/api'; + +export type SwapState = { + walletPortfolio: PortfolioData | undefined; +}; + +const initialState: SwapState = { + walletPortfolio: undefined, +}; + +const gasTankSlice = createSlice({ + name: 'gasTank', + initialState, + reducers: { + setWalletPortfolio( + state, + action: PayloadAction + ) { + state.walletPortfolio = action.payload; + }, + }, +}); + +export const { + setWalletPortfolio, +} = gasTankSlice.actions; + +export default gasTankSlice; diff --git a/src/apps/gas-tank/styles/gasTank.css b/src/apps/gas-tank/styles/gasTank.css new file mode 100644 index 00000000..2816df49 --- /dev/null +++ b/src/apps/gas-tank/styles/gasTank.css @@ -0,0 +1,17 @@ +/* Gas Tank App Specific Styles */ +.gas-tank-app { + background: #0a0a0a; + min-height: 100vh; + color: white; +} + +/* Responsive overrides for gas tank */ +@media (max-width: 768px) { + .gas-tank-app { + padding: 16px; + } +} + +.gas-tank-loading-spinner { + color: #8b5cf6 !important; +} \ No newline at end of file diff --git a/src/apps/gas-tank/types/tokens.ts b/src/apps/gas-tank/types/tokens.ts new file mode 100644 index 00000000..098ccdc9 --- /dev/null +++ b/src/apps/gas-tank/types/tokens.ts @@ -0,0 +1,30 @@ +export type SelectedToken = { + name: string; + symbol: string; + usdValue: string; + logo: string; + dailyPriceChange: number; + chainId: number; + decimals: number; + address: string; +}; + +export type PayingToken = { + name: string; + symbol: string; + logo: string; + actualBal: string; + totalUsd: number; + totalRaw: string; + chainId: number; + address: string; +}; + +export enum SearchType { + MyHoldings = 'My Holdings', +} + +export enum SortType { + Up = 'Up', + Down = 'Down', +} diff --git a/src/apps/gas-tank/types/types.ts b/src/apps/gas-tank/types/types.ts new file mode 100644 index 00000000..ee8458b3 --- /dev/null +++ b/src/apps/gas-tank/types/types.ts @@ -0,0 +1,200 @@ +export type TransactionStatusState = + | 'Starting Transaction' + | 'Transaction Pending' + | 'Transaction Complete' + | 'Transaction Failed'; + +export type TransactionStep = + | 'Submitted' + | 'Pending' + | 'ResourceLock' + | 'Completed'; + +export type StepStatus = 'completed' | 'pending' | 'failed' | 'inactive'; + +export interface TokenInfo { + symbol: string; + name: string; + logo: string; + address?: string; +} + +export interface SellOffer { + tokenAmountToReceive: number; + minimumReceive: number; +} + +export interface PayingToken { + totalUsd: number; + name: string; + symbol: string; + logo: string; + actualBal: string; + totalRaw: string; + chainId: number; + address: string; +} + +export interface TokenDetails { + symbol: string; + name: string; + address: string; + chainId: number; + amount: string; + logo: string; + type: 'BUY_TOKEN' | 'SELL_TOKEN'; +} + +export interface BuyModeDetails { + usdAmount: string; + payingTokens: PayingToken[]; + totalPayingUsd: number; +} + +export interface TransactionStatusConfig { + icon: string; + containerClasses: string; + iconClasses: string; + color: string; +} + +export interface ButtonConfig { + bgColor: string; + textColor: string; + borderColor: string; + label: string; +} + +export interface ProgressStepConfig { + label: string; + order: number; +} + +export interface TransactionStatusProps { + closeTransactionStatus: () => void; + userOpHash: string; + chainId: number; + gasFee?: string; + // Transaction data from PreviewSell/PreviewBuy + isBuy?: boolean; + sellToken?: TokenInfo | null; + buyToken?: TokenInfo | null; + tokenAmount?: string; + sellOffer?: SellOffer | null; + payingTokens?: PayingToken[]; + usdAmount?: string; + // Externalized polling state + currentStatus: TransactionStatusState; + errorDetails: string; + submittedAt?: Date; + pendingCompletedAt?: Date; + blockchainTxHash?: string; + resourceLockTxHash?: string; + completedTxHash?: string; + completedChainId?: number; + resourceLockChainId?: number; + resourceLockCompletedAt?: Date; + isResourceLockFailed?: boolean; +} + +export interface TransactionDetailsProps { + onDone: () => void; + userOpHash: string; + chainId: number; + status: TransactionStatusState; + // Transaction data from PreviewSell/PreviewBuy + isBuy?: boolean; + sellToken?: TokenInfo | null; + buyToken?: TokenInfo | null; + tokenAmount?: string; + sellOffer?: SellOffer | null; + payingTokens?: PayingToken[]; + usdAmount?: string; + submittedAt?: Date; + pendingCompletedAt?: Date; + resourceLockCompletedAt?: Date; + txHash?: string; + gasFee?: string; + errorDetails?: string; + resourceLockTxHash?: string; + completedTxHash?: string; + resourceLockChainId?: number; + completedChainId?: number; + isResourceLockFailed?: boolean; +} + +export interface TransactionInfoProps { + status: TransactionStatusState; + userOpHash: string; + txHash?: string; + chainId: number; + gasFee?: string; + completedAt?: Date; + // Buy-specific fields + isBuy?: boolean; + resourceLockTxHash?: string; + resourceLockChainId?: number; + completedTxHash?: string; + completedChainId?: number; +} + +export interface ProgressStepProps { + step: TransactionStep; + status: StepStatus; + label: string; + isLast?: boolean; + showLine?: boolean; + lineStatus?: StepStatus; + timestamp?: string | React.ReactNode; +} + +export interface TransactionErrorBoxProps { + technicalDetails?: string; +} + +export interface UseClickOutsideOptions { + ref: React.RefObject; + callback: () => void; + condition: boolean; +} + +export interface UseKeyboardNavigationOptions { + onEscape: () => void; + onEnter?: () => void; + enabled?: boolean; +} + +export interface TechnicalDetails { + transactionType: 'BUY' | 'SELL'; + transactionHash: string; + hashType: 'bidHash' | 'userOpHash'; + chainId: number; + status: TransactionStatusState; + timestamp: string; + accountAddress: string; + token: TokenDetails | null; + sellOffer: SellOffer | null; + buyMode: BuyModeDetails | null; + transactionHashes: { + [key: string]: string; + }; + chains: { + mainChainId: number; + resourceLockChainId: string | number; + completedChainId: string | number; + }; + timestamps: { + [key: string]: string; + }; + error: { + details: string; + isResourceLockFailed: boolean; + failureStep: string; + }; + gas: { + fee: string; + }; + stepStatus: { + [key: string]: StepStatus; + }; +} diff --git a/src/apps/gas-tank/utils/blockchain.ts b/src/apps/gas-tank/utils/blockchain.ts new file mode 100644 index 00000000..a044e039 --- /dev/null +++ b/src/apps/gas-tank/utils/blockchain.ts @@ -0,0 +1,141 @@ +import { BigNumber, BigNumberish } from 'ethers'; +import { formatEther, formatUnits } from 'ethers/lib/utils'; +import { decodeFunctionData, erc20Abi, parseUnits } from 'viem'; + +// types +import { + Token, + chainNameToChainIdTokensData, +} from '../../../services/tokensData'; +import { StepTransaction } from './types'; + +// utils +import { isNativeToken } from './wrappedTokens'; + +export const processBigNumber = (val: BigNumber): number => + Number(val.toString()); + +export const processEth = (val: BigNumberish, dec: number): number => { + if (typeof val === 'bigint') { + return +parseFloat(formatEther(val)).toFixed(2); + } + + return +parseFloat(formatUnits(val as BigNumberish, dec)); +}; + +// Utility: get native token symbol for a chain +export const NATIVE_SYMBOLS: Record = { + 1: 'ETH', + 100: 'xDAI', + 137: 'POL', + 10: 'ETH', + 42161: 'ETH', + 56: 'BNB', + 8453: 'ETH', +}; + +// Helper: Detect if a tx is a native fee step +export const isNativeFeeTx = ( + tx: StepTransaction, + feeReceiver: string +): boolean => { + return ( + typeof tx.to === 'string' && + typeof feeReceiver === 'string' && + tx.to.toLowerCase() === feeReceiver.toLowerCase() + ); +}; + +// Helper: Detect if a tx is an ERC20 (stablecoin or wrapped) fee step +export const isERC20FeeTx = ( + tx: StepTransaction, + swapToken: Token +): boolean => { + return ( + typeof tx.to === 'string' && + typeof swapToken.contract === 'string' && + tx.to.toLowerCase() === swapToken.contract.toLowerCase() && + tx.value === BigInt(0) && + typeof tx.data === 'string' && + tx.data.startsWith('0xa9059cbb') + ); +}; + +// Helper: Extract fee amount from tx +export const getFeeAmount = ( + tx: StepTransaction, + swapToken: Token, + decimals: number +): string => { + if (tx.value && tx.data === '0x') { + // Native + return formatEther(tx.value); + } + if (isERC20FeeTx(tx, swapToken)) { + try { + const decoded = decodeFunctionData({ + abi: erc20Abi, + data: tx.data || '0x', + }); + if ( + decoded.args && + Array.isArray(decoded.args) && + decoded.args.length > 1 && + typeof decoded.args[1] === 'bigint' + ) { + return formatUnits(decoded.args[1], decimals); + } + } catch (e) { + console.warn('Failed to decode ERC20 transfer data:', e); + return '0'; + } + } + return '0'; +}; + +// Helper: Get fee symbol +export const getFeeSymbol = ( + tx: StepTransaction, + swapToken: Token, + chainId: number +): string => { + if (tx.value && tx.data === '0x') { + return NATIVE_SYMBOLS[chainId] || 'NATIVE'; + } + return swapToken.symbol; +}; + +// Extract ERC20 token balance from walletPortfolio for a given contract and chainId +export const getTokenBalanceFromPortfolio = ( + walletPortfolio: Token[] | undefined, + contract: string, + chainId: number +): string | undefined => { + if (!walletPortfolio) return undefined; + const token = walletPortfolio.find( + (t) => + t.contract.toLowerCase() === contract.toLowerCase() && + chainNameToChainIdTokensData(t.blockchain) === chainId + ); + return token ? String(token.balance) : undefined; +}; + +// Convert to wei as bigint +export const toWei = (amount: string | number, decimals = 18): bigint => { + return parseUnits(String(amount), decimals); +}; + +// Extract native token balance from walletPortfolio for a given chainId +export const getNativeBalanceFromPortfolio = ( + walletPortfolio: Token[] | undefined, + chainId: number +): string | undefined => { + if (!walletPortfolio) return undefined; + // Find the native token for the chain (by contract address) + const nativeToken = walletPortfolio.find( + (token) => + chainNameToChainIdTokensData(token.blockchain) === chainId && + isNativeToken(token.contract) + ); + return nativeToken ? String(nativeToken.balance) : undefined; +}; diff --git a/src/apps/gas-tank/utils/constants.ts b/src/apps/gas-tank/utils/constants.ts new file mode 100644 index 00000000..03b6c1bf --- /dev/null +++ b/src/apps/gas-tank/utils/constants.ts @@ -0,0 +1,70 @@ +import { CompatibleChains, isGnosisEnabled } from '../../../utils/blockchain'; + +const allMobulaChainNames = [ + 'Ethereum', + 'Polygon', + 'Base', + 'XDAI', + 'BNB Smart Chain (BEP20)', + 'Arbitrum', + 'Optimistic', +]; + +export const MOBULA_CHAIN_NAMES = allMobulaChainNames.filter( + (name) => isGnosisEnabled || name !== 'XDAI' +); + +export enum MobulaChainNames { + Ethereum = 'Ethereum', + Polygon = 'Polygon', + Base = 'Base', + XDAI = 'XDAI', + BNB_Smart_Chain_BEP20 = 'BNB Smart Chain (BEP20)', + Arbitrum = 'Arbitrum', + Optimistic = 'Optimistic', + All = 'All', +} + +export const getChainId = (chain: MobulaChainNames) => { + switch (chain) { + case MobulaChainNames.Ethereum: + return '1'; + case MobulaChainNames.Polygon: + return '137'; + case MobulaChainNames.Base: + return '8453'; + case MobulaChainNames.XDAI: + return '100'; + case MobulaChainNames.BNB_Smart_Chain_BEP20: + return '56'; + case MobulaChainNames.Arbitrum: + return '42161'; + case MobulaChainNames.Optimistic: + return '10'; + default: + return CompatibleChains.reduce((acc, item, index) => { + return acc + (index > 0 ? ',' : '') + item.chainId; + }, ''); + } +}; + +export const getChainName = (chainId: number) => { + switch (chainId) { + case 1: + return MobulaChainNames.Ethereum; + case 137: + return MobulaChainNames.Polygon; + case 8453: + return MobulaChainNames.Base; + case 100: + return MobulaChainNames.XDAI; + case 56: + return MobulaChainNames.BNB_Smart_Chain_BEP20; + case 42161: + return MobulaChainNames.Arbitrum; + case 10: + return MobulaChainNames.Optimistic; + default: + return ''; + } +}; diff --git a/src/apps/gas-tank/utils/converters.ts b/src/apps/gas-tank/utils/converters.ts new file mode 100644 index 00000000..8ab163bb --- /dev/null +++ b/src/apps/gas-tank/utils/converters.ts @@ -0,0 +1,11 @@ +export const hasThreeZerosAfterDecimal = (num: number): boolean => { + const decimalPart = num.toString().split('.')[1] || ''; + return decimalPart.startsWith('000'); +}; + +export const formatTokenAmount = (amount?: number) => { + if (amount === undefined) return 0; + return hasThreeZerosAfterDecimal(amount) + ? amount.toFixed(8) + : amount.toFixed(4); +}; diff --git a/src/apps/gas-tank/utils/number.ts b/src/apps/gas-tank/utils/number.ts new file mode 100644 index 00000000..fcaa39fb --- /dev/null +++ b/src/apps/gas-tank/utils/number.ts @@ -0,0 +1,53 @@ +export function formatBigNumber(num: number): string { + if (num < 1_000) { + return num.toString(); + } + if (num < 1_000_000) { + return `${(num / 1_000).toFixed(3).replace(/\.?0+$/, '')}K`; + } + if (num >= 1_000_000 && num < 1_000_000_000) { + return `${(num / 1_000_000).toFixed(3).replace(/\.?0+$/, '')}M`; + } + if (num >= 1_000_000_000) { + return `${(num / 1_000_000_000).toFixed(3).replace(/\.?0+$/, '')}B`; + } + return num.toString(); +} + +export function parseNumberString(input: string): number { + const match = input.match(/^([\d,.]+)([KMB]?)$/i); + if (!match) return 0; + + const [, num, unit] = match; + let value = parseFloat(num.replace(/,/g, '')); + + switch (unit.toUpperCase()) { + case 'K': + value *= 1_000; + break; + case 'M': + value *= 1_000_000; + break; + case 'B': + value *= 1_000_000_000; + break; + default: + value *= 1; + break; + } + return value; +} + +export function bigIntPow(base: bigint, exponent: bigint): bigint { + let result = BigInt(1); + while (exponent > BigInt(0)) { + if (exponent % BigInt(2) === BigInt(1)) { + result *= base; + } + // eslint-disable-next-line no-param-reassign + base *= base; + // eslint-disable-next-line no-param-reassign + exponent /= BigInt(2); + } + return result; +} diff --git a/src/apps/gas-tank/utils/parseSearchData.ts b/src/apps/gas-tank/utils/parseSearchData.ts new file mode 100644 index 00000000..de41dc0a --- /dev/null +++ b/src/apps/gas-tank/utils/parseSearchData.ts @@ -0,0 +1,99 @@ +/* eslint-disable no-restricted-syntax */ +import { + PairResponse, + Projection, + TokenAssetResponse, + TokensMarketData, +} from '../../../types/api'; +import { + getChainName, + MOBULA_CHAIN_NAMES, + MobulaChainNames, +} from './constants'; +import { parseNumberString } from './number'; + +export type Asset = { + name: string; + symbol: string; + logo: string | null; + mCap: number | undefined; + volume: number | undefined; + price: number | null; + liquidity: number | undefined; + chain: string; + decimals: number; + contract: string; + priceChange24h: number | null; + timestamp?: number; +}; + +export function parseAssetData( + asset: TokenAssetResponse, + chains: MobulaChainNames +): Asset[] { + const result: Asset[] = []; + const { blockchains, contracts, decimals } = asset; + for (let i = 0; i < blockchains.length; i += 1) { + if ( + MOBULA_CHAIN_NAMES.includes(blockchains[i]) && + (chains === MobulaChainNames.All || chains === blockchains[i]) + ) { + result.push({ + name: asset.name, + symbol: asset.symbol, + logo: asset.logo, + mCap: asset.market_cap, + volume: asset.volume, + price: asset.price, + liquidity: asset.liquidity, + chain: blockchains[i], + decimals: decimals[i], + contract: contracts[i], + priceChange24h: asset.price_change_24h, + }); + } + } + + return result; +} + +export function parseTokenData(asset: TokenAssetResponse): Asset[] { + const result: Asset[] = []; + const { blockchains, decimals, contracts } = asset; + for (let i = 0; i < blockchains.length; i += 1) { + if (MOBULA_CHAIN_NAMES.includes(blockchains[i])) { + result.push({ + name: asset.name, + symbol: asset.symbol, + logo: asset.logo, + mCap: asset.market_cap, + volume: asset.volume_24h, + price: asset.price, + liquidity: asset.liquidity, + chain: blockchains[i], + decimals: decimals[i], + contract: contracts[i], + priceChange24h: asset.price_change_24h, + }); + } + } + return result; +} + +export function parseSearchData( + searchData: TokenAssetResponse[] | PairResponse[], + chains: MobulaChainNames +) { + const assets: Asset[] = []; + const markets: Asset[] = []; + searchData.forEach((item) => { + if (item.type === 'asset') { + assets.push(...parseAssetData(item as TokenAssetResponse, chains)); + } else if (item.type === 'token') { + assets.push(...parseTokenData(item as TokenAssetResponse)); + } + }); + + return { assets, markets }; +} + diff --git a/src/apps/gas-tank/utils/sentry.ts b/src/apps/gas-tank/utils/sentry.ts new file mode 100644 index 00000000..dc289c3b --- /dev/null +++ b/src/apps/gas-tank/utils/sentry.ts @@ -0,0 +1,247 @@ +import * as Sentry from '@sentry/react'; +import { useWalletAddress } from '@etherspot/transaction-kit'; + +// Sentry configuration for gas-tank app +export const initSentryForGasTank = () => { + Sentry.setTag('app', 'gas-tank'); + Sentry.setTag('module', 'gasTank'); +}; + +let globalWalletAddress: string | null = null; + +export const setGlobalWalletAddress = (address: string) => { + globalWalletAddress = address; +}; + +// Utility to get fallback wallet address for logging +// This function should be called from within a React component context +// where the wallet address is available +export const fallbackWalletAddressForLogging = (): string => { + return globalWalletAddress || 'unknown_wallet_address'; +}; + +// Enhanced Sentry logging with wallet address context +export const logExchangeEvent = ( + message: string, + level: Sentry.SeverityLevel = 'info', + extra?: Record, + tags?: Record +) => { + const walletAddress = fallbackWalletAddressForLogging(); + + Sentry.withScope((scope) => { + scope.setLevel(level); + scope.setTag('wallet_address', walletAddress); + scope.setTag('app_module', 'gasTank'); + + if (tags) { + Object.entries(tags).forEach(([key, value]) => { + scope.setTag(key, value); + }); + } + + if (extra) { + scope.setExtra('extraData: ', extra); + } + + Sentry.captureMessage(message); + }); +}; + +// Log exchange errors with wallet address +export const logExchangeError = ( + error: Error | string, + extra?: Record, + tags?: Record +) => { + const walletAddress = fallbackWalletAddressForLogging(); + + Sentry.withScope((scope) => { + scope.setLevel('error'); + scope.setTag('wallet_address', walletAddress); + scope.setTag('app_module', 'gasTank'); + scope.setTag('error_type', 'exchange_error'); + + if (tags) { + Object.entries(tags).forEach(([key, value]) => { + scope.setTag(key, value); + }); + } + + if (extra) { + scope.setExtra('exchange_error_data', extra); + } + + if (error instanceof Error) { + Sentry.captureException(error); + } else { + Sentry.captureMessage(error, 'error'); + } + }); +}; + +// Generic operation logging function +export const logOperation = ( + operationType: string, + operation: string, + data: Record, + walletAddress?: string +) => { + Sentry.withScope((scope) => { + scope.setLevel('info'); + scope.setTag( + 'wallet_address', + walletAddress || fallbackWalletAddressForLogging() + ); + scope.setTag('app_module', 'gasTank'); + scope.setTag('operation_type', operationType); + scope.setTag(`${operationType}_operation`, operation); + + scope.setExtra(`${operationType}_data`, data); + + Sentry.captureMessage( + `${operationType.charAt(0).toUpperCase() + operationType.slice(1)} operation: ${operation}`, + 'info' + ); + }); +}; + +// Log swap operations +export const logSwapOperation = ( + operation: string, + data: Record, + walletAddress?: string +) => { + logOperation('swap', operation, data, walletAddress); +}; + +// Log token operations +export const logTokenOperation = ( + operation: string, + tokenData: Record, + walletAddress?: string +) => { + logOperation('token', operation, tokenData, walletAddress); +}; + +// Log offer operations +export const logOfferOperation = ( + operation: string, + offerData: Record, + walletAddress?: string +) => { + logOperation('offer', operation, offerData, walletAddress); +}; + +// Log transaction operations +export const logTransactionOperation = ( + operation: string, + transactionData: Record, + walletAddress?: string +) => { + logOperation('transaction', operation, transactionData, walletAddress); +}; + +// Log user interactions +export const logUserInteraction = ( + interaction: string, + data: Record, + walletAddress?: string +) => { + Sentry.withScope((scope) => { + scope.setLevel('info'); + scope.setTag( + 'wallet_address', + walletAddress || fallbackWalletAddressForLogging() + ); + scope.setTag('app_module', 'gasTank'); + scope.setTag('interaction_type', 'user_action'); + scope.setTag('user_interaction', interaction); + + scope.setExtra('interaction_data', data); + + Sentry.captureMessage(`User interaction: ${interaction}`, 'info'); + }); +}; + +// Log performance metrics +export const logPerformanceMetric = ( + metric: string, + value: number, + unit: string = 'ms', + walletAddress?: string +) => { + Sentry.withScope((scope) => { + scope.setLevel('info'); + scope.setTag( + 'wallet_address', + walletAddress || fallbackWalletAddressForLogging() + ); + scope.setTag('app_module', 'the-exchange'); + scope.setTag('metric_type', 'performance'); + scope.setTag('metric_name', metric); + + scope.setExtra('performance_data', { + metric, + value, + unit, + timestamp: new Date().toISOString(), + }); + + Sentry.captureMessage( + `Performance metric: ${metric} = ${value}${unit}`, + 'info' + ); + }); +}; + +// Hook to get wallet address for logging +export const useWalletAddressForLogging = () => { + const walletAddress = useWalletAddress(); + return walletAddress || 'unknown_wallet_address'; +}; + +// Note: Error boundary removed due to TypeScript/JSX compatibility issues +// Use Sentry's default error boundary or create a separate React component file + +// Transaction monitoring for exchange operations +export const startExchangeTransaction = ( + operation: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _data: Record = {}, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _walletAddress?: string +) => { + return Sentry.startSpan( + { + name: `exchange.${operation}`, + op: 'exchange.operation', + }, + (span) => { + // Note: Span API has changed in Sentry v10 + // Properties are set via the span context instead + return span; + } + ); +}; + +// Breadcrumb utilities for exchange +export const addExchangeBreadcrumb = ( + message: string, + category: string = 'exchange', + data?: Record, + level: Sentry.SeverityLevel = 'info' +) => { + const walletAddress = fallbackWalletAddressForLogging(); + + Sentry.addBreadcrumb({ + message, + category, + level, + data: { + ...data, + wallet_address: walletAddress, + app_module: 'gasTank', + }, + }); +}; diff --git a/src/apps/gas-tank/utils/time.ts b/src/apps/gas-tank/utils/time.ts new file mode 100644 index 00000000..a07d86e1 --- /dev/null +++ b/src/apps/gas-tank/utils/time.ts @@ -0,0 +1,25 @@ +export function formatElapsedTime(epochSeconds?: number): string { + if (!epochSeconds) return ''; + const now = Math.floor(Date.now() / 1000); + const diff = now - epochSeconds; + + const minutes = Math.floor(diff / 60); + const hours = Math.floor(diff / 3600); + const days = Math.floor(diff / 86400); + const months = Math.floor(diff / (30 * 86400)); + const years = Math.floor(diff / (365 * 86400)); + + if (diff < 3600) { + return `${minutes}m ago`; + } + if (diff < 86400) { + return `${hours}h ago`; + } + if (diff < 30 * 86400) { + return `${days}d ago`; + } + if (diff < 12 * 30 * 86400) { + return `${months}mo ago`; + } + return `${years}y ago`; +} diff --git a/src/apps/gas-tank/utils/types.tsx b/src/apps/gas-tank/utils/types.tsx new file mode 100644 index 00000000..547049f0 --- /dev/null +++ b/src/apps/gas-tank/utils/types.tsx @@ -0,0 +1,41 @@ +import { BridgingProvider } from '@etherspot/data-utils/dist/cjs/sdk/data/constants'; +import { Route } from '@lifi/sdk'; +import { Hex } from 'viem'; + +export enum CardPosition { + SWAP = 'SWAP', + RECEIVE = 'RECEIVE', +} + +export type SwapType = { + fromAmount: number; + fromTokenAddress: string; + fromChainId: number; + fromTokenDecimals: number; + slippage?: number; + fromAccountAddress?: string; + provider?: BridgingProvider; +}; + +export type SwapOffer = { + tokenAmountToReceive: number; + offer: Route; +}; + +export type ChainType = { + chainId: number; + chainName: string; +}; + +export type StepTransaction = { + to?: string; + data?: Hex; + value?: bigint; + gasLimit?: bigint; + gasPrice?: bigint; + chainId?: number; + type?: number | string; + transactionType?: StepType; +}; + +export type StepType = 'swap' | 'cross' | 'lifi' | 'custom' | 'approval'; diff --git a/src/apps/gas-tank/utils/wrappedTokens.ts b/src/apps/gas-tank/utils/wrappedTokens.ts new file mode 100644 index 00000000..a982b246 --- /dev/null +++ b/src/apps/gas-tank/utils/wrappedTokens.ts @@ -0,0 +1,44 @@ +export const NATIVE_TOKEN_ADDRESSES = new Set([ + '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + '0x0000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000001010', +]); + +// Not including XDAI below +export const WRAPPED_NATIVE_TOKEN_ADDRESSES: Record = { + // Ethereum + 1: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH + // Polygon + 137: '0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270', // WMATIC + // Optimism + 10: '0x4200000000000000000000000000000000000006', // WETH + // Arbitrum + 42161: '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', // WETH + // Base + 8453: '0x4200000000000000000000000000000000000006', // WETH + // BNB + 56: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', // WBNB +}; + +export const isWrappedToken = (tokenAddress: string, chainId: number) => { + return ( + tokenAddress.toLowerCase() === + WRAPPED_NATIVE_TOKEN_ADDRESSES[chainId]?.toLowerCase() + ); +}; + +export const isNativeToken = (address: string) => + NATIVE_TOKEN_ADDRESSES.has(address.toLowerCase()); + +export const getWrappedTokenAddressIfNative = ( + tokenAddress: string, + chainId: number +): string => { + if (isNativeToken(tokenAddress)) { + const wrappedAddress = WRAPPED_NATIVE_TOKEN_ADDRESSES[chainId]; + // Return the original token address if no wrapped version is available + // This handles cases like XDAI or other chains without wrapped versions + return wrappedAddress || tokenAddress; + } + return tokenAddress; +}; diff --git a/src/apps/pillarx-app/components/GasTankPaymasterTile/GasTankPaymasterTile.tsx b/src/apps/pillarx-app/components/GasTankPaymasterTile/GasTankPaymasterTile.tsx new file mode 100644 index 00000000..157620e9 --- /dev/null +++ b/src/apps/pillarx-app/components/GasTankPaymasterTile/GasTankPaymasterTile.tsx @@ -0,0 +1,410 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { useWalletAddress } from '@etherspot/transaction-kit'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; + +// components +import { useGasTankHistory } from '../../../gas-tank/components/GasTankHistory'; +import useGasTankBalance from '../../../gas-tank/hooks/useGasTankBalance'; + +// assets +import gasTankIcon from '../../../gas-tank/assets/gas-tank-icon.png'; + +// types +interface HistoryEntry { + id: string; + date: string; + type: 'Top-up' | 'Spend'; + amount: string; + token: { + symbol: string; + value: string; + icon: string; + chainId: string; + }; +} + +const GasTankPaymasterTile = () => { + const walletAddress = useWalletAddress(); + const navigate = useNavigate(); + + const { + totalBalance, + isLoading: isBalanceLoading, + error: balanceError, + } = useGasTankBalance({ pauseAutoRefresh: false }); + + const { + historyData, + loading: isHistoryLoading, + error: historyError, + } = useGasTankHistory(walletAddress, { pauseAutoRefresh: false }); + + const handleTileClick = () => { + navigate('/gas-tank'); + }; + + // Show only the first 5 entries + const displayedHistory = historyData.slice(0, 5); + const loading = isBalanceLoading || isHistoryLoading; + const error = balanceError || historyError; + + return ( + +
+ + Gas Tank Paymaster + View All → +
+ + + + + {balanceError ? ( + + Error loading balance + + ) : isBalanceLoading ? ( + + ) : ( + <> + ${totalBalance.toFixed(2)} + On All Networks + + )} + + + + + {isHistoryLoading ? ( + + {Array.from({ length: 3 }).map((_, index) => ( + + + + + + + + ))} + + ) : historyError ? ( + + Unable to load gas tank history + + ) : displayedHistory.length === 0 ? ( + + No transactions yet + + ) : ( + + + # + Date + Type + Amount + Token + + + + {displayedHistory.map((entry) => ( + + {entry.id} + {entry.date} + + + {entry.type} + + + + + {entry.amount} + + + + + + + {entry.token.value} + {entry.token.symbol} + + + + + ))} + + + )} + + +
+ ); +}; + +// Styled Components +const TileContainer = styled.div` + background: #1A1B1E; + border-radius: 16px; + padding: 24px; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid rgba(255, 255, 255, 0.05); + + &:hover { + background: #1E1F23; + border-color: rgba(255, 255, 255, 0.1); + transform: translateY(-2px); + } +`; + +const Header = styled.div` + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +`; + +const IconImage = styled.img` + width: 24px; + height: 24px; + object-fit: contain; +`; + +const Title = styled.h2` + font-size: 20px; + font-weight: 600; + margin: 0; + color: #FFFFFF; + flex: 1; +`; + +const ViewAllButton = styled.span` + color: #8B5CF6; + font-size: 14px; + font-weight: 500; +`; + +const ContentContainer = styled.div` + display: flex; + gap: 32px; + + @media (max-width: 768px) { + flex-direction: column; + gap: 24px; + } +`; + +const LeftSection = styled.div` + flex: 1; + min-width: 200px; +`; + +const RightSection = styled.div` + flex: 2; +`; + +const BalanceSection = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const BalanceAmount = styled.div` + font-size: 48px; + font-weight: 600; + color: #FFFFFF; + line-height: 1; +`; + +const NetworkLabel = styled.div` + color: #3B82F6; + font-size: 12px; + font-weight: 500; +`; + +const LoadingBalance = styled.div` + width: 180px; + height: 48px; + background: linear-gradient( + 90deg, + #2A2A2A 0%, + rgba(139, 92, 246, 0.1) 50%, + #2A2A2A 100% + ); + background-size: 200% 100%; + animation: shimmer 2s infinite linear; + border-radius: 4px; + + @keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } + } +`; + +const ErrorBalance = styled.div` + color: #EF4444; + font-size: 14px; + display: flex; + flex-direction: column; + gap: 8px; +`; + +const LoadingContainer = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const LoadingRow = styled.div` + display: flex; + gap: 16px; + align-items: center; +`; + +const LoadingCell = styled.div<{ width: string }>` + width: ${({ width }) => width}; + height: 16px; + background: linear-gradient( + 90deg, + #2A2A2A 0%, + rgba(139, 92, 246, 0.1) 50%, + #2A2A2A 100% + ); + background-size: 200% 100%; + animation: shimmer 2s infinite linear; + border-radius: 4px; + + @keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } + } +`; + +const ErrorContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 32px; +`; + +const ErrorText = styled.p` + color: #EF4444; + font-size: 14px; + margin: 0; +`; + +const EmptyContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 32px; +`; + +const EmptyText = styled.p` + color: #9CA3AF; + font-size: 14px; + margin: 0; +`; + +const TableContainer = styled.div` + width: 100%; +`; + +const TableHeader = styled.div` + display: flex; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 8px; +`; + +const HeaderCell = styled.div<{ width: string }>` + width: ${({ width }) => width}; + color: #9CA3AF; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +`; + +const TableBody = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const TableRow = styled.div` + display: flex; + align-items: center; + padding: 8px 0; + border-radius: 6px; + transition: background-color 0.2s; + + &:hover { + background: rgba(255, 255, 255, 0.02); + } +`; + +const Cell = styled.div<{ width: string }>` + width: ${({ width }) => width}; + display: flex; + align-items: center; + color: #FFFFFF; + font-size: 14px; +`; + +const TypeBadge = styled.span<{ $isDeposit: boolean }>` + background: ${({ $isDeposit }) => + $isDeposit ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)'}; + color: ${({ $isDeposit }) => + $isDeposit ? '#22C55E' : '#EF4444'}; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +`; + +const Amount = styled.span<{ $isDeposit: boolean }>` + color: ${({ $isDeposit }) => + $isDeposit ? '#22C55E' : '#EF4444'}; + font-weight: 600; +`; + +const TokenInfo = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const TokenIcon = styled.img` + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; +`; + +const TokenDetails = styled.div` + display: flex; + flex-direction: column; + gap: 2px; +`; + +const TokenValue = styled.span` + color: #FFFFFF; + font-size: 12px; + font-weight: 500; +`; + +const TokenSymbol = styled.span` + color: #9CA3AF; + font-size: 10px; +`; + +export default GasTankPaymasterTile; \ No newline at end of file diff --git a/src/apps/pillarx-app/index.tsx b/src/apps/pillarx-app/index.tsx index b2ef6a2b..80689aa4 100644 --- a/src/apps/pillarx-app/index.tsx +++ b/src/apps/pillarx-app/index.tsx @@ -19,6 +19,7 @@ import { componentMap } from './utils/configComponent'; // components import AnimatedTile from './components/AnimatedTile/AnimatedTitle'; +import GasTankPaymasterTile from './components/GasTankPaymasterTile/GasTankPaymasterTile'; import SkeletonTiles from './components/SkeletonTile/SkeletonTile'; import Body from './components/Typography/Body'; import WalletPortfolioTile from './components/WalletPortfolioTile/WalletPortfolioTile'; @@ -194,6 +195,7 @@ const App = () => { className="flex flex-col gap-[40px] tablet:gap-[28px] mobile:gap-[32px]" > + {DisplayHomeFeedTiles} {(isHomeFeedFetching || isHomeFeedLoading) && page === 1 && ( <> diff --git a/src/components/BottomMenuModal/AccountModal.tsx b/src/components/BottomMenuModal/AccountModal.tsx index df56ebe8..40e2c021 100644 --- a/src/components/BottomMenuModal/AccountModal.tsx +++ b/src/components/BottomMenuModal/AccountModal.tsx @@ -72,7 +72,7 @@ const AccountModal = ({ isContentVisible }: AccountModalProps) => { isSuccess: isWalletPortfolioDataSuccess, } = useGetWalletPortfolioQuery( { wallet: accountAddress || '', isPnl: false }, - { skip: !accountAddress, refetchOnFocus: true, pollingInterval: 120000 } + { skip: !accountAddress, refetchOnFocus: false, pollingInterval: 120000 } ); const groupedTokens = useMemo(() => { diff --git a/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx b/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx index 7fdf7318..497e45d7 100644 --- a/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx +++ b/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx @@ -51,6 +51,7 @@ import { useTransactionDebugLogger } from '../../../hooks/useTransactionDebugLog import { GasConsumptions, getAllGaslessPaymasters, + getGasTankBalance, } from '../../../services/gasless'; import { useRecordPresenceMutation } from '../../../services/pillarXApiPresence'; import { getUserOperationStatus } from '../../../services/userOpStatus'; @@ -156,7 +157,8 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { setWalletConnectPayload, } = useBottomMenuModal(); const paymasterUrl = import.meta.env.VITE_PAYMASTER_URL; - const [isPaymaster, setIsPaymaster] = React.useState(false); + const gasTankPaymasterUrl = `${paymasterUrl}/gasTankPaymaster`; + const [isPaymaster, setIsPaymaster] = React.useState(true); const [paymasterContext, setPaymasterContext] = React.useState<{ mode: string; token?: string; @@ -178,7 +180,11 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { const [gasPrice, setGasPrice] = React.useState(); const [feeMin, setFeeMin] = React.useState(); const [selectedFeeType, setSelectedFeeType] = - React.useState('Gasless'); + React.useState('Native Token'); + const [gasTankBalance, setGasTankBalance] = React.useState(0); + const [fetchingBalances, setFetchingBalances] = React.useState(false); + const [defaultSelectedFeeTypeId, setDefaultSelectedFeeTypeId] = + React.useState('Gas Tank Paymaster'); const dispatch = useAppDispatch(); const walletPortfolio = useAppSelector( @@ -215,111 +221,108 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { walletPortfolioDataError, ]); - const feeTypeOptions = [ - { - id: 'Gasless', - title: 'Gasless', - type: 'token', - value: '', - }, - { - id: 'Native Token', - title: 'Native Token', - type: 'token', - value: '', - }, - ]; - - const [feeType, setFeeType] = React.useState(feeTypeOptions); + // Start with Native Token as the base fee type option + const [feeType, setFeeType] = React.useState([ + { id: 'Native Token', title: 'Native Token', type: 'token', value: '' } + ]); useEffect(() => { if (!walletPortfolio) return; const tokens = convertPortfolioAPIResponseToToken(walletPortfolio); if (!selectedAsset) return; + setFetchingBalances(true); + + // Start with Native Token + const availableFeeTypes = [ + { id: 'Native Token', title: 'Native Token', type: 'token', value: '' } + ]; + + // Check for gasless tokens first setQueryString(`?chainId=${selectedAsset.chainId}`); - getAllGaslessPaymasters(selectedAsset.chainId, tokens).then( - (paymasterObject) => { - if (paymasterObject) { - const nativeToken = tokens.filter( - (token: Token) => - isNativeToken(token.contract) && - chainNameToChainIdTokensData(token.blockchain) === - selectedAsset.chainId - ); - if (nativeToken.length > 0) { - setNativeAssetPrice(nativeToken[0]?.price || 0); - } - const feeOptions = paymasterObject - .map( - (item: { - gasToken: string; - chainId: number; - epVersion: string; - paymasterAddress: string; - // eslint-disable-next-line consistent-return, array-callback-return - }) => { - const tokenData = tokens.find( - (token: Token) => - token.contract.toLowerCase() === item.gasToken.toLowerCase() - ); - if (tokenData) - return { - id: `${item.gasToken}-${item.chainId}-${item.paymasterAddress}-${tokenData.decimals}`, - type: 'token', - title: tokenData.name, - imageSrc: tokenData.logo, - chainId: chainNameToChainIdTokensData(tokenData.blockchain), - value: tokenData.balance, - price: tokenData.price, - asset: { - ...tokenData, - contract: item.gasToken, - decimals: tokenData.decimals, - }, - balance: tokenData.balance ?? 0, - } as TokenAssetSelectOption; - } - ) - .filter( - (value): value is TokenAssetSelectOption => value !== undefined + + // Run both checks in parallel + Promise.all([ + getGasTankBalance(accountAddress ?? ''), + getAllGaslessPaymasters(selectedAsset.chainId, tokens) + ]).then(([resRaw, paymasterObject]) => { + const res = resRaw ?? 0; + setGasTankBalance(res); + setFetchingBalances(false); + setPaymasterContext(null); + + // Add Gas Tank Paymaster if balance exists + if (res > 0) { + availableFeeTypes.push({ + id: 'Gas Tank Paymaster', + title: 'Gas Tank Paymaster', + type: 'token', + value: '' + }); + } + if (paymasterObject) { + const nativeToken = tokens.filter( + (token: Token) => + isNativeToken(token.contract) && + chainNameToChainIdTokensData(token.blockchain) === + selectedAsset.chainId + ); + if (nativeToken.length > 0) { + setNativeAssetPrice(nativeToken[0]?.price || 0); + } + const feeOptions = paymasterObject + .map((item) => { + const tokenData = tokens.find( + (token) => token.contract.toLowerCase() === item.gasToken.toLowerCase() ); - if (feeOptions && feeOptions.length > 0 && feeOptions[0]) { - setFeeType(feeTypeOptions); - setFeeAssetOptions(feeOptions); - // get Skandha gas price - getGasPrice(selectedAsset.chainId).then((res) => { - setGasPrice(res); - }); - setSelectedFeeAsset({ - token: feeOptions[0].asset.contract, - decimals: feeOptions[0].asset.decimals, - tokenPrice: feeOptions[0].asset.price?.toString(), - balance: feeOptions[0].value?.toString(), - }); - setSelectedPaymasterAddress(feeOptions[0].id.split('-')[2]); - if (selectedFeeType === 'Gasless') { - setPaymasterContext({ - mode: 'commonerc20', - token: feeOptions[0].asset.contract, - }); - setIsPaymaster(true); - } - } else { - setIsPaymaster(false); - setPaymasterContext(null); - setFeeAssetOptions([]); - setFeeType([]); - } + if (tokenData) + return { + id: `${item.gasToken}-${item.chainId}-${item.paymasterAddress}-${tokenData.decimals}`, + type: 'token', + title: tokenData.name, + imageSrc: tokenData.logo, + chainId: chainNameToChainIdTokensData(tokenData.blockchain), + value: tokenData.balance, + price: tokenData.price, + asset: { + ...tokenData, + contract: item.gasToken, + decimals: tokenData.decimals, + }, + balance: tokenData.balance ?? 0, + } as TokenAssetSelectOption; + }) + .filter((value): value is TokenAssetSelectOption => value !== undefined); + + if (feeOptions.length > 0) { + // Add Gasless option if we have gasless tokens + availableFeeTypes.push({ + id: 'Gasless', + title: 'Gasless', + type: 'token', + value: '' + }); + setFeeType(availableFeeTypes); + setFeeAssetOptions(feeOptions); + setSelectedFeeType('Gasless'); + setDefaultSelectedFeeTypeId('Gasless'); + setIsPaymaster(true); } else { - setPaymasterContext(null); - setIsPaymaster(false); + setFeeType(availableFeeTypes); setFeeAssetOptions([]); - setFeeType([]); + setSelectedFeeType('Native Token'); + setDefaultSelectedFeeTypeId('Native Token'); + setIsPaymaster(false); } + } else { + setFeeType(availableFeeTypes); + setFeeAssetOptions([]); + setSelectedFeeType('Native Token'); + setDefaultSelectedFeeTypeId('Native Token'); + setIsPaymaster(false); } - ); + }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedAsset, walletPortfolio]); + }, [selectedAsset?.chainId]); const setApprovalData = async (gasCost: number) => { if (selectedFeeAsset && gasPrice && gasCost) { @@ -507,6 +510,7 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { Number(amount) > maxAmountAvailable; const onSend = async (ignoreSafetyWarning?: boolean) => { + const sendId = `send_token_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Start Sentry transaction for send flow @@ -1278,9 +1282,14 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { }); const paymasterAddress = value.id.split('-')[2]; setSelectedPaymasterAddress(paymasterAddress); + setPaymasterContext({ + mode: 'commonerc20', + token: tokenAddress, + }); }; const handleOnChangeFeeAsset = (value: SelectOption) => { + console.log('handleOnChangeFeeAsset', value, selectedFeeType); setSelectedFeeType(value.title); if (value.title === 'Gasless') { setPaymasterContext({ @@ -1288,6 +1297,11 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { token: selectedFeeAsset?.token, }); setIsPaymaster(true); + } else if (value.title === 'Gas Tank Paymaster') { + setPaymasterContext({ + mode: 'gasTankPaymaster', + }); + setIsPaymaster(true); } else { setPaymasterContext(null); setIsPaymaster(false); @@ -1333,7 +1347,7 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { paymaster={ isPaymaster ? { - url: `${paymasterUrl}${queryString}`, + url: `${selectedFeeType === 'Gasless' ? paymasterUrl : gasTankPaymasterUrl}${queryString}`, context: paymasterContext, } : undefined @@ -1343,6 +1357,7 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { {isPaymaster && selectedPaymasterAddress && + selectedFeeType === 'Gasless' && selectedFeeAsset && ( { /> - {feeType.length > 0 && feeAssetOptions.length > 0 && ( + {feeType.length > 0 && ( <> {paymasterContext?.mode === 'commonerc20' && feeAssetOptions.length > 0 && ( @@ -1550,7 +1570,7 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { paymaster={ isPaymaster ? { - url: `${paymasterUrl}${queryString}`, + url: `${selectedFeeType === 'Gasless' ? paymasterUrl : gasTankPaymasterUrl}${queryString}`, context: paymasterContext, } : undefined @@ -1560,6 +1580,7 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { {isPaymaster && selectedPaymasterAddress && + selectedFeeType === 'Gasless' && selectedFeeAsset && approveData && ( { diff --git a/src/providers/AllowedAppsProvider.tsx b/src/providers/AllowedAppsProvider.tsx index 4855cff9..1faed0cc 100644 --- a/src/providers/AllowedAppsProvider.tsx +++ b/src/providers/AllowedAppsProvider.tsx @@ -53,7 +53,13 @@ const AllowedAppsProvider = ({ children }: { children: React.ReactNode }) => { setIsLoading(false); return; } - setAllowed(data?.map((app: ApiAllowedApp) => app.appId)); + const allowedAppIds = + data?.map((app: ApiAllowedApp) => app.appId) || []; + + // Add gas-tank app for development + allowedAppIds.push('gas-tank'); + + setAllowed(allowedAppIds); } catch (e) { console.warn('Error calling PillarX apps API', e); } diff --git a/src/services/gasless.ts b/src/services/gasless.ts index 720bc4ec..d5cd84d6 100644 --- a/src/services/gasless.ts +++ b/src/services/gasless.ts @@ -11,6 +11,11 @@ export type Paymasters = { paymasterAddress: string; }; +interface ChainBalance { + chainId: string; + balance: string; +} + export const GasConsumptions = { native: 510000, native_arb: 910000, @@ -54,3 +59,35 @@ export const getAllGaslessPaymasters = async ( return null; } }; + +export const getGasTankBalance = async ( + walletAddress: string +): Promise => { + try { + const response = await fetch( + `${import.meta.env.VITE_PAYMASTER_URL}/getGasTankBalance?sender=${walletAddress}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch gas tank balance: ${response.status}`); + } + + const data = await response.json(); + const balances: ChainBalance[] = Object.values(data.balance || {}); + // Get cumalative balance for all chains + const total = balances.reduce((sum, chainBalance) => { + const balance = parseFloat(chainBalance.balance) || 0; + return sum + balance; + }, 0); + return total; + } catch (error) { + console.error('Error fetching gas tank balance:', error); + return null; + } +}; diff --git a/src/types/api.ts b/src/types/api.ts index d6794ebd..a9b5815f 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -557,6 +557,7 @@ export type TokenAssetResponse = { volume?: number; // Asset-specific twitter?: string | null; // Asset-specific website?: string | null; // Asset-specific + price_change_24h: number | null; }; export type PairResponse = { diff --git a/src/types/blockchain.ts b/src/types/blockchain.ts index 99ed202a..61b0c576 100644 --- a/src/types/blockchain.ts +++ b/src/types/blockchain.ts @@ -25,3 +25,12 @@ export interface ITransaction { data?: string; chainId: number; } + +export interface TokenListToken { + address: string; + chainId: number; + name: string; + symbol: string; + decimals: number; + logoURI?: string; +} diff --git a/src/utils/blockchain.ts b/src/utils/blockchain.ts index 8d86b858..3e18d1a5 100644 --- a/src/utils/blockchain.ts +++ b/src/utils/blockchain.ts @@ -1,8 +1,8 @@ /* eslint-disable no-restricted-syntax */ import { Nft } from '@etherspot/data-utils/dist/cjs/sdk/data/classes/nft'; import { NftCollection } from '@etherspot/data-utils/dist/cjs/sdk/data/classes/nft-collection'; -import { TokenListToken } from '@etherspot/data-utils/dist/cjs/sdk/data/classes/token-list-token'; import { ethers } from 'ethers'; +import { encodeFunctionData, erc20Abi, parseUnits } from 'viem'; import { arbitrum, avalanche, @@ -15,6 +15,12 @@ import { sepolia, } from 'viem/chains'; +// utils +import { isNativeToken } from '../apps/the-exchange/utils/wrappedTokens'; + +// types +import { TokenListToken } from '../types/blockchain'; + // images import logoArbitrum from '../assets/images/logo-arbitrum.png'; import logoAvalanche from '../assets/images/logo-avalanche.png'; @@ -34,6 +40,9 @@ export const isTestnet = (() => { return storedIsTestnet === 'true'; })(); +export const isGnosisEnabled = + import.meta.env.VITE_FEATURE_FLAG_GNOSIS === 'true'; + export const isValidEthereumAddress = ( address: string | undefined ): boolean => { @@ -84,7 +93,7 @@ export const getNativeAssetForChainId = (chainId: number): TokenListToken => { 'https://public.etherspot.io/buidler/chain_logos/ethereum.png'; } - if (chainId === gnosis.id) { + if (isGnosisEnabled && chainId === gnosis.id) { nativeAsset.name = 'XDAI'; nativeAsset.symbol = 'XDAI'; nativeAsset.logoURI = @@ -136,7 +145,7 @@ export const getNativeAssetForChainId = (chainId: number): TokenListToken => { return nativeAsset; }; -export const supportedChains = [ +const allSupportedChains = [ mainnet, polygon, gnosis, @@ -147,6 +156,10 @@ export const supportedChains = [ sepolia, ]; +export const supportedChains = allSupportedChains.filter( + (chain) => isGnosisEnabled || chain.id !== 100 +); + export const visibleChains = supportedChains.filter((chain) => isTestnet ? chain.testnet : !chain.testnet ); @@ -164,7 +177,7 @@ export const getLogoForChainId = (chainId: number): string => { return logoPolygon; } - if (chainId === gnosis.id) { + if (isGnosisEnabled && chainId === gnosis.id) { return logoGnosis; } @@ -224,7 +237,9 @@ export const getBlockScan = (chain: number, isAddress: boolean = false) => { case 8453: return `https://basescan.org/${isAddress ? 'address' : 'tx'}/`; case 100: - return `https://gnosisscan.io/${isAddress ? 'address' : 'tx'}/`; + return isGnosisEnabled + ? `https://gnosisscan.io/${isAddress ? 'address' : 'tx'}/` + : ''; case 56: return `https://bscscan.com/${isAddress ? 'address' : 'tx'}/`; case 10: @@ -245,7 +260,7 @@ export const getBlockScanName = (chain: number) => { case 8453: return 'Basescan'; case 100: - return 'Gnosisscan'; + return isGnosisEnabled ? 'Gnosisscan' : ''; case 56: return 'Bscscan'; case 10: @@ -266,7 +281,7 @@ export const getChainName = (chain: number) => { case 8453: return 'Base'; case 100: - return 'Gnosis'; + return isGnosisEnabled ? 'Gnosis' : `${chain}`; case 56: return 'BNB Smart Chain'; case 10: @@ -278,7 +293,7 @@ export const getChainName = (chain: number) => { } }; -export const CompatibleChains = [ +const allCompatibleChains = [ { chainId: 1, chainName: 'Ethereum', @@ -309,7 +324,11 @@ export const CompatibleChains = [ }, ]; -const STABLECOIN_ADDRESSES: Record> = { +export const CompatibleChains = allCompatibleChains.filter( + (chain) => isGnosisEnabled || chain.chainId !== 100 +); + +const allStablecoinAddresses: Record> = { 1: new Set([ // Ethereum mainnet '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC @@ -347,9 +366,134 @@ const STABLECOIN_ADDRESSES: Record> = { ]), }; +const STABLECOIN_ADDRESSES = Object.fromEntries( + Object.entries(allStablecoinAddresses).filter( + ([chainId]) => isGnosisEnabled || chainId !== '100' + ) +) as Record>; + export function isStableCoin(address: string, chainId: number): boolean { if (!address || !chainId) return false; const set = STABLECOIN_ADDRESSES[chainId]; if (!set) return false; return set.has(address.toLowerCase()); } + +// Simple utility to safely convert values to BigInt +export const safeBigIntConversion = (value: unknown): bigint => { + if (typeof value === 'bigint') return value; + if (typeof value === 'string' && value.includes('.')) return BigInt(0); // Skip decimal strings + try { + return BigInt(String(value || 0)); + } catch { + return BigInt(0); + } +}; + +export const buildTransactionData = ({ + tokenAddress, + recipient, + amount, + decimals, +}: { + tokenAddress: string; + recipient: string; + amount: string | bigint; + decimals: number; +}) => { + // Validate recipient address + if (!recipient || !isValidEthereumAddress(recipient)) { + throw new Error('Invalid recipient address'); + } + + // Validate amount + if (typeof amount === 'string') { + if (!amount || amount === '0' || amount === '0.0' || amount === '0.00') { + throw new Error('Invalid amount: must be a positive value'); + } + if (Number.isNaN(Number(amount)) || Number(amount) <= 0) { + throw new Error('Invalid amount: must be a positive valid number'); + } + } else if (typeof amount === 'bigint') { + if (amount <= BigInt(0)) { + throw new Error('Invalid amount: must be a positive value'); + } + } + + // Validate decimals + if (decimals < 0 || decimals > 18 || !Number.isInteger(decimals)) { + throw new Error('Invalid decimals: must be an integer between 0 and 18'); + } + + // Validate token address (for ERC20 tokens) + if (!isNativeToken(tokenAddress) && !isValidEthereumAddress(tokenAddress)) { + throw new Error('Invalid token address'); + } + + try { + // Ensure amount is properly formatted as a string with appropriate precision + const amountString = + typeof amount === 'string' ? amount : amount.toString(); + + if (isNativeToken(tokenAddress)) { + // Native token transfer + try { + const parsedValue = parseUnits(amountString, decimals); + // Ensure the parsed value is a valid bigint + if (typeof parsedValue !== 'bigint') { + throw new Error(`parseUnits returned invalid value: ${parsedValue}`); + } + return { + to: recipient, + value: parsedValue, + data: '0x', + }; + } catch (parseError) { + console.error( + 'parseUnits error:', + parseError, + 'amountString:', + amountString, + 'decimals:', + decimals + ); + throw new Error( + `Failed to parse units: ${parseError instanceof Error ? parseError.message : 'Unknown error'}` + ); + } + } + // ERC20 transfer + try { + const parsedAmount = parseUnits(amountString, decimals); + // Ensure the parsed amount is a valid bigint + if (typeof parsedAmount !== 'bigint') { + throw new Error(`parseUnits returned invalid value: ${parsedAmount}`); + } + return { + to: tokenAddress, + value: '0', + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [recipient as `0x${string}`, parsedAmount], + }), + }; + } catch (parseError) { + console.error( + 'parseUnits error:', + parseError, + 'amountString:', + amountString, + 'decimals:', + decimals + ); + throw new Error( + `Failed to parse units: ${parseError instanceof Error ? parseError.message : 'Unknown error'}` + ); + } + } catch (error) { + throw new Error( + `Failed to build transaction data: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +}; diff --git a/src/utils/number.tsx b/src/utils/number.tsx index 47d571a7..3a86f46f 100644 --- a/src/utils/number.tsx +++ b/src/utils/number.tsx @@ -1,4 +1,4 @@ -import { parseInt, parseInt as parseIntLodash } from 'lodash'; +import { isNaN, parseInt, parseInt as parseIntLodash } from 'lodash'; export const formatAmountDisplay = ( amountRaw: string | number, @@ -109,3 +109,41 @@ export const formatExponential = (num: number) => { const final = `0.${newIntPart}${fracPart}`; return final; }; + +/** + * Converts scientific notation to a readable format with zeros + * @param value - The value to format (can be string or number) + * @param maxDecimals - Maximum number of decimal places to show (default: 18) + * @returns Formatted string with zeros instead of scientific notation + */ +export const formatExponentialSmallNumber = ( + value: string | number, + maxDecimals: number = 18 +): string => { + if (!value || value === '0' || value === 0) return '0'; + + const numValue = typeof value === 'string' ? parseFloat(value) : value; + if (isNaN(numValue)) return '0'; + + // If it's not very small, return as is + if (Math.abs(numValue) >= 0.0001) { + return numValue.toString(); + } + + // For very small numbers, use toFixed to avoid scientific notation + const fixed = numValue.toFixed(maxDecimals); + + // Remove trailing zeros but keep at least one digit after decimal + const trimmed = parseFloat(fixed).toString(); + + // If it's still in scientific notation, handle it manually + if (trimmed.includes('e-')) { + const [mantissa, exponent] = trimmed.split('e-'); + const exp = parseInt(exponent); + const digits = mantissa.replace('.', ''); + const zeros = '0'.repeat(exp - 1); + return `0.${zeros}${digits}`; + } + + return trimmed; +};