diff --git a/.changeset/dull-crabs-act.md b/.changeset/dull-crabs-act.md new file mode 100644 index 000000000..61ff35188 --- /dev/null +++ b/.changeset/dull-crabs-act.md @@ -0,0 +1,6 @@ +--- +'@status-im/wallet': patch +'wallet': patch +--- + +Add value chart diff --git a/.changeset/four-dogs-build.md b/.changeset/four-dogs-build.md new file mode 100644 index 000000000..c3928ba9f --- /dev/null +++ b/.changeset/four-dogs-build.md @@ -0,0 +1,6 @@ +--- +"@status-im/wallet": patch +"wallet": patch +--- + +Add CTA to main view diff --git a/.changeset/good-lies-cover.md b/.changeset/good-lies-cover.md new file mode 100644 index 000000000..991f2679d --- /dev/null +++ b/.changeset/good-lies-cover.md @@ -0,0 +1,6 @@ +--- +'@status-im/wallet': patch +'wallet': patch +--- + +integrate pending wallet activity diff --git a/.changeset/itchy-schools-warn.md b/.changeset/itchy-schools-warn.md new file mode 100644 index 000000000..90f4fc3de --- /dev/null +++ b/.changeset/itchy-schools-warn.md @@ -0,0 +1,5 @@ +--- +"wallet": patch +--- + +Persistent recovery phrase diff --git a/.changeset/khaki-weeks-wait.md b/.changeset/khaki-weeks-wait.md new file mode 100644 index 000000000..852edb725 --- /dev/null +++ b/.changeset/khaki-weeks-wait.md @@ -0,0 +1,6 @@ +--- +"@status-im/wallet": patch +"wallet": patch +--- + +Rename tabs diff --git a/.changeset/warm-cycles-draw.md b/.changeset/warm-cycles-draw.md new file mode 100644 index 000000000..87414825f --- /dev/null +++ b/.changeset/warm-cycles-draw.md @@ -0,0 +1,5 @@ +--- +'@status-im/components': patch +--- + +Adds close toast action and multiple toasts diff --git a/.vscode/settings.json b/.vscode/settings.json index 546596f37..57a922e70 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,8 @@ { "typescript.tsdk": "node_modules/typescript/lib", + "typescript.preferences.includePackageJsonAutoImports": "on", + "typescript.suggest.autoImports": true, + "typescript.preferences.importModuleSpecifier": "relative", "npm.packageManager": "pnpm", "eslint.useESLintClass": true, "editor.formatOnSave": true, @@ -33,5 +36,11 @@ "tailwindCSS.experimental.classRegex": [ ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] - ] + ], + "search.exclude": { + "**/node_modules": true, + "**/bower_components": true, + "**/*.code-search": true, + "**/.output": true + } } diff --git a/README.md b/README.md index 605475073..d8b48822b 100644 --- a/README.md +++ b/README.md @@ -6,23 +6,23 @@ Packages for building user interfaces, websites, web applications, dapps, browse ## Packages -| Name | `npm` | Description | -| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`@status-im/colors`](./packages/colors) | [![npm version](https://img.shields.io/npm/v/@status-im/colors.svg)](https://www.npmjs.com/package/@status-im/colors) | Auto-generated color palette based on our [design system](https://www.figma.com/design/v98g9ZiaSHYUdKWrbFg9eM/Foundations?node-id=619-5995&node-type=canvas&m=dev). | -| [`@status-im/icons`](./packages/icons) | [![npm version](https://img.shields.io/npm/v/@status-im/icons)](https://www.npmjs.com/package/@status-im/icons) | Auto-generated icon library based on our [design system](https://www.figma.com/design/qLLuMLfpGxK9OfpIavwsmK/Iconset?node-id=3239-987&node-type=frame&t=0h8iIiZ3Sf0g4MRV-11). | -| [`@status-im/components`](./packages/components) | [![npm version](https://img.shields.io/npm/v/@status-im/components)](https://www.npmjs.com/package/@status-im/components) | Component library built with Radix UI, React Aria, Tailwind CSS. | -| [`@status-im/js`](./packages/status-js) | [![npm version](https://img.shields.io/npm/v/@status-im/js)](https://www.npmjs.com/package/@status-im/js) | | -| [`@status-im/wallet`](./packages/wallet) | | | -| [`@status-im/eslint-config`](./packages/eslint-config) | [![npm version](https://img.shields.io/npm/v/@status-im/eslint-config.svg)](https://www.npmjs.com/package/@status-im/eslint-config) | Shared ESLint configuration for consistent code style across projects. | +| Name | Deployments | Builds | Description | +| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`@status-im/colors`](./packages/colors) | [![vercel](https://img.shields.io/badge/vercel-black)](https://status-components.vercel.app/?path=/story/colors) | [![npm version](https://img.shields.io/npm/v/@status-im/colors.svg)](https://www.npmjs.com/package/@status-im/colors) | Auto-generated color palette based on our [design system](https://www.figma.com/design/v98g9ZiaSHYUdKWrbFg9eM/Foundations?node-id=619-5995&node-type=canvas&m=dev). | +| [`@status-im/icons`](./packages/icons) | [![vercel](https://img.shields.io/badge/vercel-black)](https://status-components.vercel.app/?path=/story/icons) | [![npm version](https://img.shields.io/npm/v/@status-im/icons)](https://www.npmjs.com/package/@status-im/icons) | Auto-generated icon library based on our [design system](https://www.figma.com/design/qLLuMLfpGxK9OfpIavwsmK/Iconset?node-id=3239-987&node-type=frame&t=0h8iIiZ3Sf0g4MRV-11). | +| [`@status-im/components`](./packages/components) | [![vercel](https://img.shields.io/badge/vercel-black)](https://status-components.vercel.app/?path=/story/components) | [![npm version](https://img.shields.io/npm/v/@status-im/components)](https://www.npmjs.com/package/@status-im/components) | Component library built with Radix UI, React Aria, Tailwind CSS. | +| [`@status-im/js`](./packages/status-js) | | [![npm version](https://img.shields.io/npm/v/@status-im/js)](https://www.npmjs.com/package/@status-im/js) | | +| [`@status-im/wallet`](./packages/wallet) | | | +| [`@status-im/eslint-config`](./packages/eslint-config) | | [![npm version](https://img.shields.io/npm/v/@status-im/eslint-config.svg)](https://www.npmjs.com/package/@status-im/eslint-config) | Shared ESLint configuration for consistent code style across projects. | ## Apps -| Name | Description | -| -------------------------------------- | ----------------------------------------------------------------------------- | -| [`./apps/connector`](./apps/connector) | Status Desktop Wallet extended to decentralised applications in your browser. | -| [`./apps/portfolio`](./apps/portfolio) | | -| [`./apps/wallet`](./apps/wallet) | | -| [`./apps/api`](./apps/api) | | +| Name | Deployments | Builds | Description | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| [`./apps/connector`](./apps/connector) | [![chrome web store](https://img.shields.io/badge/chrome-grey)](https://chromewebstore.google.com/detail/kahehnbpamjplefhpkhafinaodkkenpg) | [![jenkins job](https://img.shields.io/badge/jenkins-grey)](https://ci.status.im/job/status-web/job/main/job/connector/) | Status Desktop Wallet extended to decentralised applications in your browser. | +| [`./apps/portfolio`](./apps/portfolio) | | | | +| [`./apps/wallet`](./apps/wallet) | [![chrome web store](https://img.shields.io/badge/chrome-grey)](https://chromewebstore.google.com/detail/opkfeajbclhjdneghppfnfiannideafj) | [![jenkins job](https://img.shields.io/badge/jenkins-grey)](https://ci.status.im/job/status-web/job/main/job/wallet/) | Easily view and manage your crypto portfolio in real time — Beta crypto wallet and Web3 portfolio tracker in one. | +| [`./apps/api`](./apps/api) | | | | ## Prerequisites @@ -31,11 +31,6 @@ Required: - **[Node.js](https://nodejs.org/)** v20.x - **[pnpm](https://pnpm.io)** v9.12.x -Recommended: - -- **[Visual Studio Code](https://code.visualstudio.com/)** - - install extensions listed in `.vscode/extensions.json` for optimal development experience - ## Stack - **Turborepo**: Manages our monorepo and speeds up builds @@ -91,10 +86,6 @@ pnpm storybook This will start the Storybook server, allowing you to browse and test components in isolation. -## Continuous Integration - -The builds of `main` branch are available in [Jenkins CI](https://ci.status.im/job/status-web/job/main/). - ## Sponsors This project is sponsored by Browserstack. diff --git a/apps/api/src/app/api/trpc/[trpc]/route.ts b/apps/api/src/app/api/trpc/[trpc]/route.ts index c95c6042c..47e372917 100644 --- a/apps/api/src/app/api/trpc/[trpc]/route.ts +++ b/apps/api/src/app/api/trpc/[trpc]/route.ts @@ -18,7 +18,10 @@ export const runtime = 'edge' export const dynamic = 'force-dynamic' async function handler(request: NextRequest) { - return fetchRequestHandler({ + // let error: Error | undefined + + // const response = await fetchRequestHandler({ + return await fetchRequestHandler({ endpoint: '/api/trpc', router: apiRouter, req: request, @@ -28,10 +31,17 @@ async function handler(request: NextRequest) { return { headers } }, + /** + * @see https://trpc.io/docs/v10/server/error-handling#handling-errors + */ // onError: opts => { - // // console.error('opts::', opts) + // error = opts.error.cause // }, responseMeta: opts => { + // note: opts.error does not have original cause (status code), contrary to onError + // note!: status code is inferred from TRPCError.code (TOO_MANY_REQUESTS, INTERNAL_SERVER_ERROR, etc.) + // const error = opts.errors?.[0] + let cacheControl = 'public, max-age=3600' if ( @@ -40,6 +50,7 @@ async function handler(request: NextRequest) { 'nodes.broadcastTransaction', 'nodes.getNonce', 'nodes.getTransactionCount', + 'nodes.getFeeRate', 'activities.page', 'activities.activities', 'assets.all', @@ -54,6 +65,7 @@ async function handler(request: NextRequest) { } return { + // status: 429, headers: { 'cache-control': cacheControl, 'Access-Control-Allow-Origin': '*', @@ -62,18 +74,16 @@ async function handler(request: NextRequest) { }, } }, + // unstable_onChunk: undefined, }) -} -export { handler as GET, handler as POST } + // const result = await response.json() -export async function OPTIONS() { - return new Response(null, { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - }, - }) + // return Response.json( + // result + // // { status: result.httpStatus } + // // { status: 429 } + // ) } + +export { handler as GET, handler as POST } diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index aa059f716..a5eb227dc 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -27,7 +27,7 @@ "forceConsistentCasingInFileNames": true, "target": "ESNext", "module": "ESNext", - "moduleResolution": "Bundler", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, // @see https://github.com/orgs/pnpm/discussions/4331#discussioncomment-3379386 for pnpm's config recommendation @@ -45,5 +45,10 @@ ".next/types/**/*.ts", "global.d.ts" ], - "exclude": ["node_modules", ".next"] + "exclude": ["node_modules", ".next"], + "references": [ + { + "path": "../../packages/wallet" + } + ] } diff --git a/apps/api/vercel.json b/apps/api/vercel.json index 81cd65354..4e28c1c5f 100644 --- a/apps/api/vercel.json +++ b/apps/api/vercel.json @@ -2,5 +2,5 @@ "$schema": "https://openapi.vercel.sh/vercel.json", "ignoreCommand": "git diff --quiet HEAD^ HEAD ../../{patches,package.json,turbo.json} ../../packages/wallet ./", "installCommand": "pnpm install --dir ../../ --frozen-lockfile", - "buildCommand": "turbo run build --cwd ../../ --filter=api..." + "buildCommand": "turbo run build --cwd ../../ --filter=./apps/api..." } diff --git a/apps/connector/tsconfig.json b/apps/connector/tsconfig.json index bbc16a084..9a41aaa0f 100644 --- a/apps/connector/tsconfig.json +++ b/apps/connector/tsconfig.json @@ -12,4 +12,9 @@ "strict": true, "target": "ESNext", }, + "references": [ + { + "path": "../../packages/colors", + }, + ], } diff --git a/apps/portfolio/src/app/[address]/@detail/assets/[ticker]/page.tsx b/apps/portfolio/src/app/[address]/@detail/assets/[ticker]/page.tsx index 3b7ad4a48..a73052e79 100644 --- a/apps/portfolio/src/app/[address]/@detail/assets/[ticker]/page.tsx +++ b/apps/portfolio/src/app/[address]/@detail/assets/[ticker]/page.tsx @@ -111,9 +111,7 @@ async function Token({
@@ -138,7 +136,7 @@ async function Token({
diff --git a/apps/portfolio/src/app/[address]/@detail/assets/_components/chart/index.tsx b/apps/portfolio/src/app/[address]/@detail/assets/_components/chart/index.tsx index 6e89f7171..eee8e7f7a 100644 --- a/apps/portfolio/src/app/[address]/@detail/assets/_components/chart/index.tsx +++ b/apps/portfolio/src/app/[address]/@detail/assets/_components/chart/index.tsx @@ -58,7 +58,7 @@ const Chart = ({ const [activeTimeFrame, setActiveTimeFrame] = useState('24H') // Todo: Currency should be dynamic - const currency = 'EUR' + const currency = 'USD' return (
diff --git a/apps/portfolio/src/app/[address]/@list/collectibles/_components/collectibles-table.tsx b/apps/portfolio/src/app/[address]/@list/collectibles/_components/collectibles-table.tsx index f657f970c..9ac5b38b4 100644 --- a/apps/portfolio/src/app/[address]/@list/collectibles/_components/collectibles-table.tsx +++ b/apps/portfolio/src/app/[address]/@list/collectibles/_components/collectibles-table.tsx @@ -48,7 +48,6 @@ const CollectiblesGrid = ({ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ - refetchOnWindowFocus: false, queryKey: ['collectibles', address, networks, search, sort], queryFn: async ({ pageParam = { offset: 0 } }) => { const response = await getCollectibles({ diff --git a/apps/portfolio/src/app/_components/buy-crypto-drawer.tsx b/apps/portfolio/src/app/_components/buy-crypto-drawer.tsx index 44dc2de6f..cc480b4ab 100644 --- a/apps/portfolio/src/app/_components/buy-crypto-drawer.tsx +++ b/apps/portfolio/src/app/_components/buy-crypto-drawer.tsx @@ -25,7 +25,7 @@ export const BuyCryptoDrawer = (props: Props) => { name: provider, network, address: account.address, - asset: asset || 'EUR', + asset: asset || 'USD', }) } diff --git a/apps/portfolio/src/app/_components/network-filter.tsx b/apps/portfolio/src/app/_components/network-filter.tsx index 36d3cdb97..7f3faca72 100644 --- a/apps/portfolio/src/app/_components/network-filter.tsx +++ b/apps/portfolio/src/app/_components/network-filter.tsx @@ -17,7 +17,7 @@ const NETWORKS = [ }, ] as const -// const CURRENCY = 'EUR' +// const CURRENCY = 'USD' // const formatCurrency = (amount: number, currency?: string) => { // return Intl.NumberFormat('en-US', { diff --git a/apps/portfolio/src/app/_providers/accounts-context.tsx b/apps/portfolio/src/app/_providers/accounts-context.tsx index 341986faf..970ed44d1 100644 --- a/apps/portfolio/src/app/_providers/accounts-context.tsx +++ b/apps/portfolio/src/app/_providers/accounts-context.tsx @@ -55,7 +55,6 @@ export const AccountsProvider = ({ children }: { children: ReactNode }) => { queryFn: () => getAccountsData(addresses, networks), enabled: addresses.length > 0, staleTime: 60000, - refetchOnWindowFocus: false, }) const addAccount = (account: Account) => { diff --git a/apps/portfolio/src/app/_providers/query-client-provider.tsx b/apps/portfolio/src/app/_providers/query-client-provider.tsx index 48f5d0e7a..56fd8ef57 100644 --- a/apps/portfolio/src/app/_providers/query-client-provider.tsx +++ b/apps/portfolio/src/app/_providers/query-client-provider.tsx @@ -2,7 +2,17 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -const queryClient = new QueryClient() +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + retryOnMount: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + }, +}) type Props = { children: React.ReactNode diff --git a/apps/portfolio/tsconfig.json b/apps/portfolio/tsconfig.json index e69ebdffc..2c23797be 100644 --- a/apps/portfolio/tsconfig.json +++ b/apps/portfolio/tsconfig.json @@ -27,7 +27,7 @@ "forceConsistentCasingInFileNames": true, "target": "ESNext", "module": "ESNext", - "moduleResolution": "Bundler", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, // @see https://github.com/orgs/pnpm/discussions/4331#discussioncomment-3379386 for pnpm's config recommendation @@ -48,8 +48,17 @@ "exclude": ["node_modules", ".next"], // @see https://github.com/nrwl/nx/issues/3106#issuecomment-703154400 "references": [ - // { - // "path": "../../packages/wallet" - // } + { + "path": "../../packages/wallet" + }, + { + "path": "../../packages/colors" + }, + { + "path": "../../packages/components" + }, + { + "path": "../../packages/icons" + } ] } diff --git a/apps/portfolio/vercel.json b/apps/portfolio/vercel.json index 699e655ff..05619f10d 100644 --- a/apps/portfolio/vercel.json +++ b/apps/portfolio/vercel.json @@ -2,5 +2,5 @@ "$schema": "https://openapi.vercel.sh/vercel.json", "ignoreCommand": "git diff --quiet HEAD^ HEAD ../../{patches,package.json,turbo.json} ../../packages/{colors,icons,components,wallet} ./", "installCommand": "pnpm install --dir ../../ --frozen-lockfile", - "buildCommand": "turbo run build --cwd ../../ --filter=portfolio..." + "buildCommand": "turbo run build --cwd ../../ --filter=./apps/portfolio..." } diff --git a/apps/wallet/README.md b/apps/wallet/README.md index 973f79fc5..d3af3deea 100644 --- a/apps/wallet/README.md +++ b/apps/wallet/README.md @@ -33,11 +33,11 @@ Google Chrome > Window > Extensions > Load unpacked > select build (.output/chro #### Visit -Google Chrome > Extensions > Status Portfolio Wallet > Inspect views > service worker +Google Chrome > Extensions > !Status Portfolio Wallet (Beta) > Inspect views > service worker -Google Chrome > Toolbar > Extensions > Status Portfolio Wallet +Google Chrome > Toolbar > Extensions > !Status Portfolio Wallet (Beta) -Google Chrome > Toolbar > Extensions > Status Portfolio Wallet > Open side panel +Google Chrome > Toolbar > Extensions > !Status Portfolio Wallet (Beta) > Open side panel chrome-extension://\[ID]/page.html#/portfolio diff --git a/apps/wallet/env.d.ts b/apps/wallet/env.d.ts index 93fb35103..0d9f3b619 100644 --- a/apps/wallet/env.d.ts +++ b/apps/wallet/env.d.ts @@ -1,10 +1,10 @@ -// /// +/// -// // import 'vite/client' +interface ImportMetaEnv { + readonly WXT_STATUS_API_URL: string + readonly WXT_GETBLOCK_API_KEY: string +} interface ImportMeta { - readonly env: { - readonly WXT_STATUS_API_URL: string - readonly WXT_GETBLOCK_API_KEY: string - } + readonly env: ImportMetaEnv } diff --git a/apps/wallet/package.json b/apps/wallet/package.json index 519915d81..6e86aa47a 100644 --- a/apps/wallet/package.json +++ b/apps/wallet/package.json @@ -16,12 +16,12 @@ "dev": "pnpm dev:chrome", "dev:chrome": "wxt -b chrome -p 4000 --mode development", "dev:firefox": "wxt -b firefox -p 4000 --mode development", - "build": "pnpm build:chrome", + "build": "pnpm build:chrome && pnpm build:types", "build:chrome": "wxt build -b chrome --mode production", "build:firefox": "wxt build -b firefox --mode production", + "build:types": "tsc --noEmit", "zip:chrome": "wxt zip -b chrome", "zip:firefox": "wxt zip -b firefox", - "compile": "tsc --noEmit", "lint": "eslint src", "format": "prettier --write .", "clean": "rimraf .wxt .output node_modules .turbo" @@ -50,6 +50,7 @@ "bitcore-lib": "^10.8.0", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", + "ethers": "^6.15.0", "long": "^5.3.1", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -75,6 +76,7 @@ "@types/react": "^19.0.1", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", + "@wxt-dev/auto-icons": "^1.0.2", "@wxt-dev/module-react": "^1.1.2", "autoprefixer": "^10.4.19", "globals": "^15.12.0", diff --git a/apps/wallet/src/assets/icon.png b/apps/wallet/src/assets/icon.png new file mode 100644 index 000000000..8f6fbe92e Binary files /dev/null and b/apps/wallet/src/assets/icon.png differ diff --git a/apps/wallet/src/components/action-buttons.tsx b/apps/wallet/src/components/action-buttons.tsx index 876cc97ba..d21fffc45 100644 --- a/apps/wallet/src/components/action-buttons.tsx +++ b/apps/wallet/src/components/action-buttons.tsx @@ -6,7 +6,7 @@ import { useState } from 'react' import { Button } from '@status-im/components' import { RefreshIcon } from '@status-im/icons/20' -import { DropdownSort } from '@status-im/wallet/components' +// import { DropdownSort } from '@status-im/wallet/components' import { useIsFetching, useQueryClient } from '@tanstack/react-query' import { useRefetchToast } from '../hooks/use-refetch-toast' @@ -23,7 +23,7 @@ import { TabLink } from './tab-link' // } // const placeholderText = { -// assets: 'Search asset name or symbol', +// assets: 'Search token name or symbol', // collectibles: 'Search collection or collectible name', // } as const @@ -40,14 +40,14 @@ type Props = { } const ActionButtons = (props: Props) => { - const { address, searchAndSortValues } = props + const { + address, + // searchAndSortValues + } = props const queryClient = useQueryClient() const [isManualRefreshing, setIsManualRefreshing] = useState(false) - const totalFetchingCount = useIsFetching() - const isAnyQueryFetching = totalFetchingCount > 0 - - // Use the refetch toast hook for manual refresh + // Use the refetch toast hook for manual refreshes useRefetchToast({ isRefreshing: isManualRefreshing, queryKeys: [ @@ -57,8 +57,31 @@ const ActionButtons = (props: Props) => { ['collectible'], ['token'], ], + onComplete: () => { + setIsManualRefreshing(false) + }, }) + const isAnyQueryFetching = + useIsFetching({ + predicate: query => { + const key = query.queryKey + return ( + (Array.isArray(key) && + key[0] === 'assets' && + (key[1] === address || key[1] === undefined)) || + (Array.isArray(key) && + key[0] === 'collectibles' && + (key[1] === address || key[1] === undefined)) || + (Array.isArray(key) && + key[0] === 'activities' && + (key[1] === address || key[1] === undefined)) || + (Array.isArray(key) && key[0] === 'collectible') || + (Array.isArray(key) && key[0] === 'token') + ) + }, + }) > 0 + // const placeholder = placeholderText[checkPathnameAndReturnTabValue(pathname)] const handleRefresh = async () => { @@ -70,11 +93,15 @@ const ActionButtons = (props: Props) => { const hasObservers = query.getObserversCount() > 0 const isWalletQuery = - (Array.isArray(key) && key[0] === 'assets' && key[1] === address) || + (Array.isArray(key) && + key[0] === 'assets' && + (key[1] === address || key[1] === undefined)) || (Array.isArray(key) && key[0] === 'collectibles' && - key[1] === address) || - (Array.isArray(key) && key[0] === 'activities' && key[1] === address) || + (key[1] === address || key[1] === undefined)) || + (Array.isArray(key) && + key[0] === 'activities' && + (key[1] === address || key[1] === undefined)) || (Array.isArray(key) && key[0] === 'collectible') || (Array.isArray(key) && key[0] === 'token') @@ -86,16 +113,14 @@ const ActionButtons = (props: Props) => { queryClient.refetchQueries({ queryKey: query.queryKey }), ), ) - - setTimeout(() => setIsManualRefreshing(false), 100) } return (
- Assets + Tokens Collectibles - Activity + History
{/* { aria-label="Refresh data" disabled={isAnyQueryFetching} /> - + /> */}
) diff --git a/apps/wallet/src/components/recovery-phrase-backup/index.tsx b/apps/wallet/src/components/recovery-phrase-backup/index.tsx index ee26b6976..27ea2ba52 100644 --- a/apps/wallet/src/components/recovery-phrase-backup/index.tsx +++ b/apps/wallet/src/components/recovery-phrase-backup/index.tsx @@ -1,5 +1,5 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useToast } from '@status-im/components' import { NegativeStateIcon } from '@status-im/icons/20' @@ -7,44 +7,41 @@ import { RecoveryPhraseDialog, SignTransactionDialog, } from '@status-im/wallet/components' +import { ERROR_MESSAGES } from '@status-im/wallet/constants' import { apiClient } from '@/providers/api-client' import { useWallet } from '@/providers/wallet-context' +import { useRecoveryPhraseBackup } from '../../hooks/use-recovery-phrase-backup' + export function RecoveryPhraseBackup() { - const { currentWallet, mnemonic, setMnemonic } = useWallet() - const [showRecoveryDialog, setShowRecoveryDialog] = useState(false) + const { currentWallet } = useWallet() + const { + needsBackup, + showRecoveryDialog, + setShowRecoveryDialog, + markAsBackedUp, + } = useRecoveryPhraseBackup() + const [mnemonic, setMnemonic] = useState(null) const toast = useToast() - useEffect(() => { - if (mnemonic) { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - e.preventDefault() - return "You haven't backed up your wallet! Your recovery phrase will be lost forever." - } - - window.addEventListener('beforeunload', handleBeforeUnload) - return () => - window.removeEventListener('beforeunload', handleBeforeUnload) - } - }, [mnemonic]) - const onPasswordConfirm = async (password: string) => { if (!currentWallet?.id) { - toast.negative('No wallet selected', { duration: 3000 }) + toast.negative(ERROR_MESSAGES.NO_WALLET_SELECTED, { duration: 3000 }) return } try { - await apiClient.wallet.get.query({ + const { mnemonic } = await apiClient.wallet.get.query({ password: password, walletId: currentWallet?.id, }) + setMnemonic(mnemonic) setShowRecoveryDialog(true) } catch (error) { console.error(error) - toast.negative('Invalid password', { + toast.negative(ERROR_MESSAGES.INVALID_PASSWORD, { duration: 3000, }) } @@ -55,14 +52,15 @@ export function RecoveryPhraseBackup() { } const onComplete = async () => { + await markAsBackedUp() setMnemonic(null) toast.positive( - 'Your recovery phrase has been deleted from the wallet interface.', + 'Your recovery phrase has been removed from this wallet interface.', { duration: 3000 }, ) } - if (!mnemonic) { + if (!needsBackup) { return null } diff --git a/apps/wallet/src/components/splitted-layout.tsx b/apps/wallet/src/components/splitted-layout.tsx index f2b052a0e..37ee5ce4e 100644 --- a/apps/wallet/src/components/splitted-layout.tsx +++ b/apps/wallet/src/components/splitted-layout.tsx @@ -156,10 +156,10 @@ const HeaderRightSlot = () => { href="/portfolio/assets" className="w-full justify-center text-center sm:w-fit" > - Assets + Tokens Collectibles - Activity + History
) } diff --git a/apps/wallet/src/data/api.ts b/apps/wallet/src/data/api.ts index 2485a1ec7..5ba5ff3d4 100644 --- a/apps/wallet/src/data/api.ts +++ b/apps/wallet/src/data/api.ts @@ -320,6 +320,55 @@ const apiRouter = router({ maxInclusionFeePerGas: input.maxInclusionFeePerGas, }) + return { + id, + } + }), + + sendErc20: t.procedure + .input( + z.object({ + walletId: z.string(), + password: z.string(), + fromAddress: z.string(), + toAddress: z.string(), + gasLimit: z.string(), + maxFeePerGas: z.string(), + maxInclusionFeePerGas: z.string(), + data: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const { keyStore, walletCore } = ctx + + const wallet = await keyStore.load(input.walletId) + + const account = wallet.activeAccounts.find( + account => account.address === input.fromAddress, + ) + + if (!account) { + throw new Error('From address not found') + } + + const privateKey = await keyStore.getKey( + wallet.id, + input.password, + account, + ) + + const id = await ethereum.sendErc20({ + walletCore, + walletPrivateKey: privateKey, + chainID: '01', + toAddress: input.toAddress, + fromAddress: input.fromAddress, + gasLimit: input.gasLimit, + maxFeePerGas: input.maxFeePerGas, + maxInclusionFeePerGas: input.maxInclusionFeePerGas, + data: input.data, + }) + return { id, } diff --git a/apps/wallet/src/data/bitcoin/bitcoin.ts b/apps/wallet/src/data/bitcoin/bitcoin.ts index 80dd909bc..38a41062c 100644 --- a/apps/wallet/src/data/bitcoin/bitcoin.ts +++ b/apps/wallet/src/data/bitcoin/bitcoin.ts @@ -105,7 +105,7 @@ export async function send({ const utxo = selectedUtxos.map(utxo => { return encoder.Bitcoin.Proto.UnspentTransaction.create({ outPoint: { - hash: Buffer.from(utxo.txid, 'hex').reverse(), + hash: new Uint8Array(Buffer.from(utxo.txid, 'hex').reverse()), index: utxo.vout, sequence: 4294967295, // default sequence // sequence: UINT32_MAX, diff --git a/apps/wallet/src/data/ethereum/ethereum.ts b/apps/wallet/src/data/ethereum/ethereum.ts index 309915c9c..0d3ba026f 100644 --- a/apps/wallet/src/data/ethereum/ethereum.ts +++ b/apps/wallet/src/data/ethereum/ethereum.ts @@ -4,6 +4,10 @@ import { encoder } from '../encoder' import type { WalletCore } from '@trustwallet/wallet-core' +const BROADCAST_TRANSACTION_URL = new URL( + `${import.meta.env.WXT_STATUS_API_URL}/api/trpc/nodes.broadcastTransaction`, +) + export async function send({ walletCore, walletPrivateKey, @@ -27,54 +31,114 @@ export async function send({ maxFeePerGas: string maxInclusionFeePerGas: string }) { - const nonceUrl = new URL( - `${import.meta.env.WXT_STATUS_API_URL}/api/trpc/nodes.getNonce`, - ) - nonceUrl.searchParams.set( - 'input', - JSON.stringify({ json: { address: fromAddress, network } }), + // Fetch nonce + const nonceHex = await getNonceHex(fromAddress, network) + const chainIdHex = getChainIdHex(chainID) + + const cleanAmount = amount.replace(/^0x/, '').padStart(16, '0') + + const txInput = encoder.Ethereum.Proto.SigningInput.create({ + chainId: Uint8Array.from(Buffer.from(chainIdHex, 'hex')), + nonce: Uint8Array.from(Buffer.from(nonceHex, 'hex')), + gasLimit: Uint8Array.from(Buffer.from(gasLimit.replace(/^0x/, ''), 'hex')), + maxFeePerGas: Uint8Array.from(Buffer.from(padHex(maxFeePerGas), 'hex')), + maxInclusionFeePerGas: Uint8Array.from( + Buffer.from(padHex(maxInclusionFeePerGas), 'hex'), + ), + toAddress, + transaction: { + transfer: { + amount: Uint8Array.from(Buffer.from(cleanAmount, 'hex')), + }, + }, + privateKey: walletPrivateKey.data(), + txMode: encoder.Ethereum.Proto.TransactionMode.Enveloped, + }) + + const inputEncoded = + encoder.Ethereum.Proto.SigningInput.encode(txInput).finish() + // sign + const outputData = walletCore.AnySigner.sign( + inputEncoded, + walletCore.CoinType.ethereum, ) - const nonceResponse = await fetch(nonceUrl.toString(), { - method: 'GET', + const output = encoder.Ethereum.Proto.SigningOutput.decode(outputData) + const rawTx = walletCore.HexCoding.encode(output.encoded) + + // broadcast + const response = await fetch(BROADCAST_TRANSACTION_URL.toString(), { + method: 'POST', headers: { 'Content-Type': 'application/json', }, + body: JSON.stringify({ + json: { + txHex: rawTx, + network, + }, + }), cache: 'no-store', }) - if (!nonceResponse.ok) { - throw new Error('Failed to fetch nonce') + if (!response.ok) { + throw new Error('Failed to broadcast transaction') } - const nonceBody = await nonceResponse.json() + const body = await response.json() + const txid = body.result.data.json - const nonce = nonceBody.result.data.json + return { + txid, + } +} - // const feeRate = nodes.getFeeRate +export async function sendErc20({ + walletCore, + walletPrivateKey, + chainID, + toAddress, + fromAddress, + network = 'ethereum', + gasLimit, + maxFeePerGas, + maxInclusionFeePerGas, + data, +}: { + walletCore: WalletCore + walletPrivateKey: InstanceType + chainID: string + toAddress: string + fromAddress: string + network?: string + gasLimit: string + maxFeePerGas: string + maxInclusionFeePerGas: string + data: string +}) { + // Fetch nonce + const nonceHex = await getNonceHex(fromAddress, network) + const chainIdHex = getChainIdHex(chainID) + // For erc20Transfer, we need to extract the recipient address and amount from the data field + // data contains function signature (4 bytes) + to address (32 bytes) + amount (32 bytes) + const cleanData = data.replace(/^0x/, '') + // Extract recipientAddress from cleanData (bytes 4-36, but only last 20 bytes are used) + const recipientAddress = '0x' + cleanData.slice(32, 72) + //Extract amount (bytes 72-104) which is 32 bytes + const tokenAmount = cleanData.slice(72, 136) - // fixme: calc nonce and fees const txInput = encoder.Ethereum.Proto.SigningInput.create({ - chainId: Uint8Array.from(Buffer.from(chainID, 'hex')), - // chainId: Buffer.from('01', 'hex'), - // gasPrice: Buffer.from(feeRate.replace('0x', ''), 'hex'), - // nonce: Buffer.from('09', 'hex'), - // nonce: Buffer.from('00', 'hex'), - nonce: Uint8Array.from(Buffer.from(nonce.replace(/^0x/, '0'), 'hex')), - // maxFeePerGas: Buffer.from(feeRate, 'hex'), - // // maxInclusionFeePerGas: Buffer.from('3b9aca00', 'hex'), - // maxInclusionFeePerGas: Buffer.from('01', 'hex'), - // gasLimit: Buffer.from('5208', 'hex'), - // gasPrice: Buffer.from('04a817c800', 'hex'), - // gasLimit: Buffer.from('5208', 'hex'), - gasLimit: Uint8Array.from(Buffer.from(gasLimit, 'hex')), - maxFeePerGas: Uint8Array.from(Buffer.from(maxFeePerGas, 'hex')), + chainId: Uint8Array.from(Buffer.from(chainIdHex, 'hex')), + nonce: Uint8Array.from(Buffer.from(nonceHex, 'hex')), + gasLimit: Uint8Array.from(Buffer.from(padHex(gasLimit), 'hex')), + maxFeePerGas: Uint8Array.from(Buffer.from(padHex(maxFeePerGas), 'hex')), maxInclusionFeePerGas: Uint8Array.from( - Buffer.from(maxInclusionFeePerGas, 'hex'), + Buffer.from(padHex(maxInclusionFeePerGas), 'hex'), ), - toAddress: toAddress, + toAddress, transaction: { - transfer: { - amount: Uint8Array.from(Buffer.from(amount, 'hex')), + erc20Transfer: { + to: recipientAddress, + amount: Uint8Array.from(Buffer.from(tokenAmount, 'hex')), }, }, privateKey: walletPrivateKey.data(), @@ -84,7 +148,6 @@ export async function send({ const inputEncoded = encoder.Ethereum.Proto.SigningInput.encode(txInput).finish() - // sign const outputData = walletCore.AnySigner.sign( inputEncoded, walletCore.CoinType.ethereum, @@ -93,11 +156,7 @@ export async function send({ const rawTx = walletCore.HexCoding.encode(output.encoded) // broadcast - const url = new URL( - `${import.meta.env.WXT_STATUS_API_URL}/api/trpc/nodes.broadcastTransaction`, - ) - - const response = await fetch(url.toString(), { + const response = await fetch(BROADCAST_TRANSACTION_URL.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -105,7 +164,7 @@ export async function send({ body: JSON.stringify({ json: { txHex: rawTx, - network: 'ethereum', + network, }, }), cache: 'no-store', @@ -122,3 +181,47 @@ export async function send({ txid, } } + +/** + * UTILS + */ + +// Function to pad hex strings to even length +const padHex = (hexStr: string) => { + hexStr = hexStr.replace(/^0x/, '') + return hexStr.length % 2 === 1 ? '0' + hexStr : hexStr +} + +// Fetch the nonce for the given address and network +const getNonceHex = async ( + fromAddress: string, + network: string, +): Promise => { + const nonceUrl = new URL( + `${import.meta.env.WXT_STATUS_API_URL}/api/trpc/nodes.getNonce`, + ) + nonceUrl.searchParams.set( + 'input', + JSON.stringify({ json: { address: fromAddress, network } }), + ) + + const nonceResponse = await fetch(nonceUrl.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }) + + if (!nonceResponse.ok) { + throw new Error('Failed to fetch nonce') + } + + const nonceBody = await nonceResponse.json() + return nonceBody.result.data.json.replace(/^0x/, '').padStart(2, '0') +} + +// Get the chain ID in hex format +const getChainIdHex = (chainID: string): string => { + return BigInt(chainID).toString(16).padStart(2, '0') +} diff --git a/apps/wallet/src/entrypoints/background.ts b/apps/wallet/src/entrypoints/background.ts index 82e3e80b6..678bdeb72 100644 --- a/apps/wallet/src/entrypoints/background.ts +++ b/apps/wallet/src/entrypoints/background.ts @@ -4,6 +4,7 @@ /// import { Buffer } from 'buffer' +import { defineBackground } from 'wxt/sandbox' // import { browser as wxtBrowser } from 'wxt/browser' import { createAPI } from '../data/api' diff --git a/apps/wallet/src/entrypoints/page/index.html b/apps/wallet/src/entrypoints/page/index.html index e7cb32dd3..114f5e3b2 100644 --- a/apps/wallet/src/entrypoints/page/index.html +++ b/apps/wallet/src/entrypoints/page/index.html @@ -6,8 +6,12 @@ - Status Portfolio Wallet + !Status Portfolio Wallet (Beta) + + + +
diff --git a/apps/wallet/src/hooks/use-activities.ts b/apps/wallet/src/hooks/use-activities.ts index 33d22a802..6f8e3a57b 100644 --- a/apps/wallet/src/hooks/use-activities.ts +++ b/apps/wallet/src/hooks/use-activities.ts @@ -89,8 +89,5 @@ export const useActivities = ({ address }: Props) => { initialPageParam: {}, staleTime: QUERY_STALE_TIME_MS, gcTime: QUERY_GC_TIME_MS, - refetchOnMount: true, - refetchOnWindowFocus: true, - refetchOnReconnect: true, }) } diff --git a/apps/wallet/src/hooks/use-assets.ts b/apps/wallet/src/hooks/use-assets.ts index 722b772fe..d41ffce11 100644 --- a/apps/wallet/src/hooks/use-assets.ts +++ b/apps/wallet/src/hooks/use-assets.ts @@ -45,7 +45,7 @@ const useAssets = ( }) if (!response.ok) { - throw new Error('Failed to fetch.') + throw new Error(response.statusText, { cause: response.status }) } const body = await response.json() @@ -55,9 +55,6 @@ const useAssets = ( enabled: !!address && !isWalletLoading, staleTime: QUERY_STALE_TIME_MS, gcTime: QUERY_GC_TIME_MS, - refetchOnMount: true, - refetchOnWindowFocus: true, - refetchOnReconnect: true, }) } diff --git a/apps/wallet/src/hooks/use-collectibles.ts b/apps/wallet/src/hooks/use-collectibles.ts index a54f7665e..855150738 100644 --- a/apps/wallet/src/hooks/use-collectibles.ts +++ b/apps/wallet/src/hooks/use-collectibles.ts @@ -66,7 +66,7 @@ const getCollectibles = async ( }) if (!response.ok) { - throw new Error('Failed to fetch.') + throw new Error(response.statusText, { cause: response.status }) } const body = await response.json() @@ -118,9 +118,6 @@ const useCollectibles = (props: Props) => { enabled: !!address && !isWalletLoading, staleTime: QUERY_STALE_TIME_MS, gcTime: QUERY_GC_TIME_MS, - refetchOnMount: true, - refetchOnWindowFocus: true, - refetchOnReconnect: true, }) } diff --git a/apps/wallet/src/hooks/use-create-wallet.ts b/apps/wallet/src/hooks/use-create-wallet.ts index f5c953c5f..ad22792eb 100644 --- a/apps/wallet/src/hooks/use-create-wallet.ts +++ b/apps/wallet/src/hooks/use-create-wallet.ts @@ -1,20 +1,21 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useAPI } from '../providers/api-client' +import { useRecoveryPhraseBackup } from './use-recovery-phrase-backup' export const useCreateWallet = () => { const api = useAPI() const queryClient = useQueryClient() + const { markAsNeedsBackup } = useRecoveryPhraseBackup() const { mutate, mutateAsync, ...result } = useMutation({ mutationKey: ['create-wallet'], mutationFn: async (password: string) => { - const { mnemonic } = await api.wallet.add.mutate({ + await api.wallet.add.mutate({ password, - name: 'Created Wallet', + name: 'Account 1', }) - - return mnemonic + await markAsNeedsBackup() }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['wallets'] }) diff --git a/apps/wallet/src/hooks/use-eth-balance.ts b/apps/wallet/src/hooks/use-eth-balance.ts index fd14bb61f..3a4632939 100644 --- a/apps/wallet/src/hooks/use-eth-balance.ts +++ b/apps/wallet/src/hooks/use-eth-balance.ts @@ -39,8 +39,5 @@ export const useEthBalance = (address: string, enabled: boolean = true) => { enabled, staleTime: 30 * 1000, gcTime: 60 * 1000, - refetchOnMount: true, - refetchOnWindowFocus: true, - refetchOnReconnect: true, }) } diff --git a/apps/wallet/src/hooks/use-import-wallet.ts b/apps/wallet/src/hooks/use-import-wallet.ts index b74090c3e..5bedae32e 100644 --- a/apps/wallet/src/hooks/use-import-wallet.ts +++ b/apps/wallet/src/hooks/use-import-wallet.ts @@ -18,7 +18,7 @@ export const useImportWallet = () => { await api.wallet.import.mutate({ mnemonic, password, - name: 'Imported Wallet', + name: 'Account 1', }) }, onSuccess: () => { diff --git a/apps/wallet/src/hooks/use-pending-transactions-cleanup.ts b/apps/wallet/src/hooks/use-pending-transactions-cleanup.ts new file mode 100644 index 000000000..9a4f821cd --- /dev/null +++ b/apps/wallet/src/hooks/use-pending-transactions-cleanup.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react' + +import { getTransactionHash } from '@status-im/wallet/utils' + +import { usePendingTransactions } from '../providers/pending-transactions-context' + +import type { ApiOutput } from '@status-im/wallet/data' + +type Activity = ApiOutput['activities']['activities']['activities'][0] + +export const usePendingTransactionsCleanup = (activities: Activity[]) => { + const { pendingTransactions, removePendingTransaction } = + usePendingTransactions() + + useEffect(() => { + if (pendingTransactions.length === 0 || activities.length === 0) { + return + } + + const confirmedHashes = new Set( + activities.map(activity => getTransactionHash(activity.hash)), + ) + + const TEN_MINUTES_MS = 10 * 60 * 1000 + const now = Date.now() + + pendingTransactions.forEach(pendingTx => { + const pendingHash = getTransactionHash(pendingTx.hash) + + if (confirmedHashes.has(pendingHash)) { + removePendingTransaction(pendingHash) + return + } + + const timestamp = pendingTx.metadata?.blockTimestamp + if (timestamp) { + const txTime = new Date(timestamp).getTime() + if (!isNaN(txTime) && now - txTime > TEN_MINUTES_MS) { + removePendingTransaction(pendingHash) + } + } + }) + }, [activities, pendingTransactions, removePendingTransaction]) +} diff --git a/apps/wallet/src/hooks/use-recovery-phrase-backup.tsx b/apps/wallet/src/hooks/use-recovery-phrase-backup.tsx new file mode 100644 index 000000000..b22690605 --- /dev/null +++ b/apps/wallet/src/hooks/use-recovery-phrase-backup.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react' + +import { useToast } from '@status-im/components' +import { ERROR_MESSAGES } from '@status-im/wallet/constants' + +export const useRecoveryPhraseBackup = () => { + const toast = useToast() + const [needsBackup, setNeedsBackup] = useState(false) + const [showRecoveryDialog, setShowRecoveryDialog] = useState(false) + + const markAsBackedUp = async () => { + try { + await chrome.storage.local.remove(['recovery-phrase:needs-backup']) + setNeedsBackup(false) + } catch (error) { + console.error('Failed to mark recovery phrase as backed up', error) + toast.negative(ERROR_MESSAGES.RECOVERY_PHRASE_BACKUP, { + duration: 3000, + }) + } + } + + const markAsNeedsBackup = async () => { + try { + await chrome.storage.local.set({ 'recovery-phrase:needs-backup': true }) + setNeedsBackup(true) + } catch (error) { + console.error('Failed to mark recovery phrase as needing backup', error) + } + } + + useEffect(() => { + async function checkSettings() { + try { + const { 'recovery-phrase:needs-backup': needsBackup } = + await chrome.storage.local.get(['recovery-phrase:needs-backup']) + if (needsBackup) { + setNeedsBackup(true) + return + } + setNeedsBackup(false) + } catch (error) { + console.error('Failed to get recovery phrase backup status', error) + setNeedsBackup(false) + } + } + + checkSettings() + }, []) + + return { + needsBackup, + markAsBackedUp, + markAsNeedsBackup, + showRecoveryDialog, + setShowRecoveryDialog, + } +} diff --git a/apps/wallet/src/hooks/use-refetch-toast.tsx b/apps/wallet/src/hooks/use-refetch-toast.tsx index 45136204c..a257ad359 100644 --- a/apps/wallet/src/hooks/use-refetch-toast.tsx +++ b/apps/wallet/src/hooks/use-refetch-toast.tsx @@ -2,20 +2,26 @@ import { useEffect, useRef } from 'react' import { useToast } from '@status-im/components' import { RefreshIcon } from '@status-im/icons/20' -import { useIsFetching } from '@tanstack/react-query' +import { useIsFetching, useQueryClient } from '@tanstack/react-query' interface UseRefetchToastOptions { isRefreshing: boolean queryKeys?: string[][] + onComplete?: () => void + showLoadingAndSuccess?: boolean } export function useRefetchToast({ isRefreshing, queryKeys, + onComplete, + showLoadingAndSuccess = true, }: UseRefetchToastOptions) { const toast = useToast() + const queryClient = useQueryClient() const hasShownLoadingToast = useRef(false) const hasShownSuccessToast = useRef(false) + const hasShownErrorToast = useRef(false) const fetchingCount = useIsFetching( queryKeys @@ -30,28 +36,61 @@ export function useRefetchToast({ : undefined, ) + const hasError = queryKeys + ? queryClient + .getQueryCache() + .getAll() + .some( + query => + queryKeys.some( + key => + query.queryKey.length >= key.length && + key.every((k, index) => query.queryKey[index] === k), + ) && query.state.status === 'error', + ) + : false + useEffect(() => { if (!isRefreshing) { hasShownLoadingToast.current = false hasShownSuccessToast.current = false + hasShownErrorToast.current = false return } if (fetchingCount > 0 && !hasShownLoadingToast.current) { hasShownLoadingToast.current = true - toast.custom( - 'Refreshing prices and balances', - , - ) + if (showLoadingAndSuccess) { + toast.custom( + 'Refreshing prices and balances', + , + ) + } } if ( fetchingCount === 0 && hasShownLoadingToast.current && - !hasShownSuccessToast.current + !hasShownSuccessToast.current && + !hasError ) { hasShownSuccessToast.current = true - toast.positive('Prices and balances have been updated') + if (showLoadingAndSuccess) { + toast.positive('Prices and balances have been updated') + } + + if (onComplete) { + onComplete() + } + } + + if (hasError && !hasShownErrorToast.current) { + hasShownErrorToast.current = true + toast.negative('Failed to update prices and balances') + + if (onComplete) { + onComplete() + } } - }, [fetchingCount, isRefreshing, toast]) + }, [fetchingCount, isRefreshing, toast, hasError, onComplete]) } diff --git a/apps/wallet/src/hooks/use-synchronized-refetch.tsx b/apps/wallet/src/hooks/use-synchronized-refetch.tsx index 616fad8bc..ef545ab45 100644 --- a/apps/wallet/src/hooks/use-synchronized-refetch.tsx +++ b/apps/wallet/src/hooks/use-synchronized-refetch.tsx @@ -5,7 +5,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useRefetchToast } from './use-refetch-toast' const REFRESH_INTERVAL_MS = 15 * 1000 -const REFRESH_COMPLETE_DELAY_MS = 50 export function useSynchronizedRefetch(address: string) { const queryClient = useQueryClient() @@ -15,6 +14,10 @@ export function useSynchronizedRefetch(address: string) { const refetchQueries = useCallback(async () => { if (!address) return + if (has429Error(address, queryClient)) { + return + } + setIsAutoRefreshing(true) const queries = queryClient.getQueryCache().getAll() @@ -23,6 +26,9 @@ export function useSynchronizedRefetch(address: string) { const key = query.queryKey const hasObservers = query.getObserversCount() > 0 + const hasValidAddress = + Array.isArray(key) && key[1] && key[1] !== 'undefined' + const isWalletQuery = (Array.isArray(key) && key[0] === 'assets' && key[1] === address) || (Array.isArray(key) && @@ -32,29 +38,33 @@ export function useSynchronizedRefetch(address: string) { (Array.isArray(key) && key[0] === 'collectible') || (Array.isArray(key) && key[0] === 'token') - return hasObservers && isWalletQuery + return hasObservers && isWalletQuery && hasValidAddress }) await Promise.all( - activeQueries.map(query => - queryClient.refetchQueries({ queryKey: query.queryKey }), - ), + activeQueries.map(async query => { + await queryClient.invalidateQueries({ queryKey: query.queryKey }) + await queryClient.refetchQueries({ + queryKey: query.queryKey, + exact: true, + }) + }), ) - - setTimeout(() => setIsAutoRefreshing(false), REFRESH_COMPLETE_DELAY_MS) }, [address, queryClient]) useRefetchToast({ isRefreshing: isAutoRefreshing, - queryKeys: address - ? [ - ['assets', address], - ['collectibles', address], - ['activities', address], - ['collectible'], - ['token'], - ] - : [], + queryKeys: [ + ['assets', address], + ['collectibles', address], + ['activities', address], + ['collectible'], + ['token'], + ], + showLoadingAndSuccess: false, + onComplete: () => { + setIsAutoRefreshing(false) + }, }) useEffect(() => { @@ -82,3 +92,57 @@ export function useSynchronizedRefetch(address: string) { return () => clearInterval(interval) }, [isWindowActive, address, queryClient, refetchQueries]) } + +function has429Error( + address: string, + queryClient: ReturnType, +) { + const queries = queryClient.getQueryCache().getAll() + return queries.some(query => { + const key = query.queryKey + const isRelevant = + (Array.isArray(key) && key[0] === 'assets' && key[1] === address) || + (Array.isArray(key) && key[0] === 'collectibles' && key[1] === address) || + (Array.isArray(key) && key[0] === 'activities' && key[1] === address) || + (Array.isArray(key) && key[0] === 'collectible') || + (Array.isArray(key) && key[0] === 'token') + + if (!isRelevant) { + return false + } + + const error = query.state.error + + if (!error) { + return false + } + + if (error.cause === 429) { + return true + } + + if (typeof error === 'object' && error !== null) { + if ( + 'status' in error && + typeof (error as { status?: unknown }).status === 'number' && + (error as { status: number }).status === 429 + ) { + return true + } + + if ( + 'message' in error && + typeof (error as { message?: unknown }).message === 'string' && + (error as { message: string }).message.includes('429') + ) { + return true + } + } + + if (typeof error === 'string' && (error as string).includes('429')) { + return true + } + + return false + }) +} diff --git a/apps/wallet/src/hooks/use-value-chart-data.ts b/apps/wallet/src/hooks/use-value-chart-data.ts new file mode 100644 index 000000000..5d9ee61b5 --- /dev/null +++ b/apps/wallet/src/hooks/use-value-chart-data.ts @@ -0,0 +1,64 @@ +import { useMemo } from 'react' + +import type { ChartDataType } from '@status-im/wallet/components' +import type { ApiOutput } from '@status-im/wallet/data' + +type UseValueChartDataParams = { + activeDataType: ChartDataType + priceData: ApiOutput['assets']['tokenPriceChart'] | null | undefined + balanceData: ApiOutput['assets']['tokenBalanceChart'] | null | undefined +} + +export function useValueChartData({ + activeDataType, + priceData, + balanceData, +}: UseValueChartDataParams) { + return useMemo(() => { + if ( + activeDataType !== 'value' || + !priceData || + !balanceData || + balanceData.length === 0 + ) { + return [] + } + + const balancePoints = balanceData + .map(point => [new Date(point.date).getTime(), point.price] as const) + .sort((a, b) => a[0] - b[0]) + + const findBalance = (targetTime: number): number => { + let left = 0 + let right = balancePoints.length - 1 + let bestBalance = 0 + + while (left <= right) { + const mid = Math.floor((left + right) / 2) + const [pointTime, balance] = balancePoints[mid] + + if (pointTime <= targetTime) { + bestBalance = balance + left = mid + 1 + } else { + right = mid - 1 + } + } + + return bestBalance + } + + return priceData.map(pricePoint => { + const targetTime = new Date(pricePoint.date).getTime() + const balance = findBalance(targetTime) + const value = balance * pricePoint.price + + return { + date: pricePoint.date, + price: pricePoint.price, + balance: balance, + value: value, + } + }) + }, [activeDataType, priceData, balanceData]) +} diff --git a/apps/wallet/src/lib/markdown.ts b/apps/wallet/src/lib/markdown.ts index a03728db3..ab54bde86 100644 --- a/apps/wallet/src/lib/markdown.ts +++ b/apps/wallet/src/lib/markdown.ts @@ -14,7 +14,6 @@ export const renderMarkdown = async ( const result = await unified() .use(remarkParse) .use(remarkRehype) - // @ts-expect-error - rehype-react types are not compatible with unified types .use(rehypeReact, { ...production, components, diff --git a/apps/wallet/src/main.tsx b/apps/wallet/src/main.tsx index 87a55baba..a5f0f97dd 100644 --- a/apps/wallet/src/main.tsx +++ b/apps/wallet/src/main.tsx @@ -9,6 +9,14 @@ import { QueryClientProvider } from './providers/query-client' import { RouterProvider } from './providers/router' import { StatusProvider } from './providers/status' +// if (import.meta.env.MODE !== 'production') { +// const script = document.createElement('script') +// script.src = 'http://localhost:8097' +// // document.body.appendChild(script) +// // document.documentElement.appendChild(script) +// document.body.append(script) +// } + const rootElement = document.getElementById('root')! if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement) diff --git a/apps/wallet/src/providers/pending-transactions-context.tsx b/apps/wallet/src/providers/pending-transactions-context.tsx new file mode 100644 index 000000000..577e87d73 --- /dev/null +++ b/apps/wallet/src/providers/pending-transactions-context.tsx @@ -0,0 +1,112 @@ +import { createContext, useContext, useEffect, useState } from 'react' + +import { getTransactionHash } from '@status-im/wallet/utils' + +import { Storage } from '../data/storage' + +import type { ApiOutput } from '@status-im/wallet/data' + +type Activity = ApiOutput['activities']['activities']['activities'][0] + +type PendingTransaction = Activity & { + status: 'pending' + uniqueId: string +} + +type PendingTransactionsContext = { + pendingTransactions: PendingTransaction[] + addPendingTransaction: ( + transaction: Omit, + ) => void + removePendingTransaction: (hash: string) => void + clearPendingTransactions: () => void + isLoading: boolean +} + +const PendingTransactionsContext = createContext< + PendingTransactionsContext | undefined +>(undefined) + +export function usePendingTransactions() { + const context = useContext(PendingTransactionsContext) + if (!context) { + throw new Error( + 'usePendingTransactions must be used within PendingTransactionsProvider', + ) + } + return context +} + +export function PendingTransactionsProvider({ + children, +}: { + children: React.ReactNode +}) { + const [pendingTransactions, setPendingTransactions] = useState< + PendingTransaction[] + >([]) + const [isLoading, setIsLoading] = useState(true) + const [storage] = useState(() => new Storage('pending-transactions')) + + useEffect(() => { + const loadPendingTransactions = async () => { + try { + const result = await storage.get(['transactions']) + const stored = result.transactions as PendingTransaction[] | undefined + + if (stored && Array.isArray(stored)) { + setPendingTransactions(stored) + } + } catch (error) { + console.error('Failed to load pending transactions:', error) + } finally { + setIsLoading(false) + } + } + + loadPendingTransactions() + }, [storage]) + + useEffect(() => { + if (!isLoading) { + storage.set({ transactions: pendingTransactions }).catch(error => { + console.error('Failed to save pending transactions:', error) + }) + } + }, [pendingTransactions, storage, isLoading]) + + const addPendingTransaction = ( + transaction: Omit, + ) => { + const newTransaction: PendingTransaction = { + ...transaction, + uniqueId: `pending-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + } + + setPendingTransactions(prev => [...prev, newTransaction]) + } + + const removePendingTransaction = (hash: string) => { + setPendingTransactions(prev => + prev.filter(tx => getTransactionHash(tx.hash) !== hash), + ) + } + + const clearPendingTransactions = () => { + setPendingTransactions([]) + } + + const contextValue: PendingTransactionsContext = { + pendingTransactions, + addPendingTransaction, + removePendingTransaction, + clearPendingTransactions, + isLoading, + } + + return ( + + {children} + + ) +} diff --git a/apps/wallet/src/providers/wallet-context.tsx b/apps/wallet/src/providers/wallet-context.tsx index 33ed5bd5a..9d5746f96 100644 --- a/apps/wallet/src/providers/wallet-context.tsx +++ b/apps/wallet/src/providers/wallet-context.tsx @@ -1,8 +1,15 @@ -import { createContext, useContext, useEffect, useState } from 'react' +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' import { useQuery } from '@tanstack/react-query' -// import { useSynchronizedRefetch } from '../hooks/use-synchronized-refetch' +import { useSynchronizedRefetch } from '../hooks/use-synchronized-refetch' import { apiClient } from './api-client' import type { KeyStore } from '@trustwallet/wallet-core' @@ -15,8 +22,6 @@ type WalletContext = { isLoading: boolean hasWallets: boolean setCurrentWallet: (id: Wallet['id']) => void - setMnemonic: (mnemonic: string | null) => void - mnemonic: string | null } const WalletContext = createContext(undefined) @@ -31,16 +36,12 @@ export function useWallet() { export function WalletProvider({ children }: { children: React.ReactNode }) { const [selectedWalletId, setSelectedWalletId] = useState(null) - const [mnemonic, setMnemonic] = useState(null) const { data: wallets = [], isLoading } = useQuery({ queryKey: ['wallets'], queryFn: () => apiClient.wallet.all.query(), staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 60 * 60 * 1000, // 1 hour - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, }) const hasWallets = wallets.length > 0 @@ -64,12 +65,20 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { } }, [hasWallets, selectedWalletId, wallets]) - const setCurrentWallet = (id: string) => { - setSelectedWalletId(id) - } + const setCurrentWallet = useCallback( + (id: string) => { + const walletExists = wallets.some(w => w.id === id) + if (walletExists) { + setSelectedWalletId(id) + } else { + console.error(`Wallet with id ${id} not found`) + } + }, + [wallets], + ) // Auto-refresh - // useSynchronizedRefetch(currentWallet?.activeAccounts[0]?.address ?? '') + useSynchronizedRefetch(currentWallet?.activeAccounts[0]?.address ?? '') const contextValue: WalletContext = { currentWallet, @@ -77,8 +86,6 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { isLoading, hasWallets, setCurrentWallet, - setMnemonic, - mnemonic, } return ( diff --git a/apps/wallet/src/routes/__root.tsx b/apps/wallet/src/routes/__root.tsx index e39c57f6f..916403678 100644 --- a/apps/wallet/src/routes/__root.tsx +++ b/apps/wallet/src/routes/__root.tsx @@ -10,18 +10,18 @@ import { // Navigate, Outlet, redirect, - useRouterState, } from '@tanstack/react-router' // import { TanStackRouterDevtools } from '@tanstack/router-devtools' // import { NotAllowed } from '../../../portfolio/src/app/_components/not-allowed' // import { AccountsProvider } from '../../../portfolio/src/app/_providers/accounts-context' // import { ConnectKitProvider } from '../../../portfolio/src/app/_providers/connectkit-provider' -// import { QueryClientProvider } from '../../../portfolio/src/app/_providers/query-client-provider' +import { QueryClientProvider } from '../../../portfolio/src/app/_providers/query-client-provider' // import { StatusProvider } from '../../../portfolio/src/app/_providers/status-provider' import { WagmiProvider } from '../../../portfolio/src/app/_providers/wagmi-provider' import { Link } from '../components/link' import { apiClient } from '../providers/api-client' +import { PendingTransactionsProvider } from '../providers/pending-transactions-context' import { WalletProvider } from '../providers/wallet-context' // import { Inter } from 'next/font/google' @@ -38,23 +38,36 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ beforeLoad: async ({ location }) => { - const wallets = await apiClient.wallet.all.query() - const hasWallets = wallets && wallets.length > 0 + try { + const wallets = await apiClient.wallet.all.query() + const hasWallets = Array.isArray(wallets) && wallets.length > 0 - if (location.pathname === '/') { - if (hasWallets) { - throw redirect({ to: '/portfolio/assets' }) - } else { - throw redirect({ to: '/onboarding' }) + if (location.pathname === '/') { + if (hasWallets) { + throw redirect({ to: '/portfolio/assets' }) + } else { + throw redirect({ to: '/onboarding' }) + } } - } - if (location.pathname.startsWith('/portfolio') && !hasWallets) { - throw redirect({ to: '/onboarding' }) - } + if (location.pathname.startsWith('/portfolio') && !hasWallets) { + throw redirect({ to: '/onboarding' }) + } - if (location.pathname.startsWith('/onboarding') && hasWallets) { - throw redirect({ to: '/portfolio/assets' }) + if (location.pathname.startsWith('/onboarding') && hasWallets) { + throw redirect({ to: '/portfolio/assets' }) + } + } catch (error) { + if (error && typeof error === 'object' && 'isRedirect' in error) { + throw error + } + console.error('Error loading wallets in beforeLoad:', error) + if ( + location.pathname === '/' || + location.pathname.startsWith('/portfolio') + ) { + throw redirect({ to: '/onboarding' }) + } } }, head: () => ({ @@ -67,17 +80,55 @@ export const Route = createRootRouteWithContext<{ content: 'width=device-width, initial-scale=1', }, { - title: 'Status Portfolio Wallet', + title: '!Status Portfolio Wallet (Beta)', }, ], }), component: RootComponent, + /** + * what + * + * - Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops. + * - etc. + * + * when + * + * - on refreshing + */ + errorComponent: function RootErrorComponent({ error }) { + console.error('RootComponent: Error:', error) + + return ( + <> +
+ +
+
+
+
+ {/* todo: global error illustration or split view */} +

+ Something went wrong. +

+

+ Refresh or try again later. +

+
+
+
+ + ) + }, + notFoundComponent: function RootNotFoundComponent() { + console.info('RootNotFoundComponent: Info: not found') + + // todo: global not found illustration + // fixme?: show feedback in navbar + return + }, }) function RootComponent() { - const routerState = useRouterState() - const pathname = routerState.location.pathname - return ( <> {/*
@@ -98,34 +149,31 @@ function RootComponent() {
{/* */} - {/* */} - {/* Loading...
}> */} - {/* */} - {/* */} - -
- -
-
-
-
- {/* */} - + + {/* Loading...
}> */} + {/* */} + {/* */} + + +
+
-
- {/* */} - -
-
- {/*
*/} - {/*
*/} - {/* */} - {/* */} +
+
+
+ {/* */} + +
+
+ {/* */} + +
+ + + {/* */} + {/* */} + {/* */} + {/* */}
diff --git a/apps/wallet/src/routes/onboarding/import.tsx b/apps/wallet/src/routes/onboarding/import.tsx index 77feea8d2..70a28c469 100644 --- a/apps/wallet/src/routes/onboarding/import.tsx +++ b/apps/wallet/src/routes/onboarding/import.tsx @@ -116,20 +116,21 @@ function CreatePassword({ }) { const { importWalletAsync } = useImportWallet() const navigate = useNavigate() - const [isPending, startTransition] = useTransition() + const [isLoading, setIsLoading] = useState(false) const handleSubmit: SubmitHandler = async data => { + setIsLoading(true) try { await importWalletAsync({ mnemonic, password: data.password, }) - startTransition(() => { - navigate({ to: '/portfolio/assets' }) - }) + navigate({ to: '/portfolio/assets' }) } catch (error) { console.error(error) + } finally { + setIsLoading(false) } } @@ -153,7 +154,7 @@ function CreatePassword({
diff --git a/apps/wallet/src/routes/onboarding/index.tsx b/apps/wallet/src/routes/onboarding/index.tsx index 7ca2a6a1a..cd1fb0b8d 100644 --- a/apps/wallet/src/routes/onboarding/index.tsx +++ b/apps/wallet/src/routes/onboarding/index.tsx @@ -33,16 +33,16 @@ function RouteComponent() { By continuing you agree with Status
Terms of use {' '} and{' '} = async data => { + setIsLoading(true) try { - const mnemonic = await createWalletAsync(data.password) - - startTransition(() => { - setMnemonic(mnemonic) - navigate({ to: '/portfolio/assets' }) - }) + await createWalletAsync(data.password) + navigate({ to: '/portfolio/assets' }) } catch (error) { console.error(error) + } finally { + setIsLoading(false) } } @@ -53,7 +49,7 @@ function RouteComponent() { only on your device. Status can't recover it.
- + ) diff --git a/apps/wallet/src/routes/portfolio/activity/index.tsx b/apps/wallet/src/routes/portfolio/activity/index.tsx index 94fd0610d..02bdb585e 100644 --- a/apps/wallet/src/routes/portfolio/activity/index.tsx +++ b/apps/wallet/src/routes/portfolio/activity/index.tsx @@ -1,13 +1,21 @@ +import { useEffect } from 'react' + +import { useToast } from '@status-im/components' import { ActivityList, ActivityListSkeleton, EmptyState, - FeedbackSection, + EmptyStateActions, + PinExtension, } from '@status-im/wallet/components' +import { ERROR_MESSAGES } from '@status-im/wallet/constants' import { createFileRoute } from '@tanstack/react-router' import SplittedLayout from '@/components/splitted-layout' import { useActivities } from '@/hooks/use-activities' +import { usePendingTransactionsCleanup } from '@/hooks/use-pending-transactions-cleanup' +import { usePinExtension } from '@/hooks/use-pin-extension' +import { usePendingTransactions } from '@/providers/pending-transactions-context' import { useWallet } from '@/providers/wallet-context' export const Route = createFileRoute('/portfolio/activity/')({ @@ -16,36 +24,65 @@ export const Route = createFileRoute('/portfolio/activity/')({ function RouteComponent() { const { currentWallet, isLoading: isWalletLoading } = useWallet() + const { isPinExtension, handleClose } = usePinExtension() + const { pendingTransactions } = usePendingTransactions() const address = currentWallet?.activeAccounts[0].address - const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = - useActivities({ address }) + const toast = useToast() + + const { + data, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isError, + } = useActivities({ address }) const activities = data?.pages.flatMap(page => page.activities) ?? [] + usePendingTransactionsCleanup(activities) + + // Show error toast if there is an error fetching activities + useEffect(() => { + if (isError) { + toast.negative(ERROR_MESSAGES.ACTIVITIES) + } + }, [isError, toast]) + + if (!currentWallet || !address) return null + return ( - 0 && address ? ( - - ) : ( -
- {!address ? ( -

No wallet selected

- ) : ( - - )} -
- ) - } - loadingState={} - detail={} - isLoading={isLoading || isWalletLoading} - /> + <> + 0 && address ? ( + + ) : ( +
+ {!address ? ( +

No wallet selected

+ ) : ( + + )} +
+ ) + } + loadingState={} + detail={} + isLoading={isLoading || isWalletLoading} + /> + {isPinExtension && ( +
+ +
+ )} + ) } diff --git a/apps/wallet/src/routes/portfolio/assets/$ticker.tsx b/apps/wallet/src/routes/portfolio/assets/$ticker.tsx index afdd4404e..65a96e419 100644 --- a/apps/wallet/src/routes/portfolio/assets/$ticker.tsx +++ b/apps/wallet/src/routes/portfolio/assets/$ticker.tsx @@ -41,29 +41,25 @@ function Component() {
{ - const ticker = url.split('/').pop() - if (!ticker) return - router.navigate({ - to: '/portfolio/assets/$ticker', - params: { ticker }, - ...(!isDesktop && { - viewTransition: true, - }), - }) - }} - clearSearch={() => { - console.log('Search cleared') - }} - searchParams={new URLSearchParams()} - pathname={pathname} - /> - ) : ( -
Empty state
- ) + { + const ticker = url.split('/').pop() + if (!ticker) return + router.navigate({ + to: '/portfolio/assets/$ticker', + params: { ticker }, + ...(!isDesktop && { + viewTransition: true, + }), + }) + }} + clearSearch={() => { + console.log('Search cleared') + }} + searchParams={new URLSearchParams()} + pathname={pathname} + /> } loadingState={} detail={} diff --git a/apps/wallet/src/routes/portfolio/assets/-components/asset-chart.tsx b/apps/wallet/src/routes/portfolio/assets/-components/asset-chart.tsx index 1b127adc8..8028c52bf 100644 --- a/apps/wallet/src/routes/portfolio/assets/-components/asset-chart.tsx +++ b/apps/wallet/src/routes/portfolio/assets/-components/asset-chart.tsx @@ -7,6 +7,8 @@ import { import { useQuery } from '@tanstack/react-query' import { notFound } from '@tanstack/react-router' +import { useValueChartData } from '../../../../hooks/use-value-chart-data' + import type { ChartDataType, ChartTimeFrame, @@ -91,11 +93,9 @@ function AssetChart({ const body = await response.json() return body.result.data.json }, + enabled: activeDataType === 'price' || activeDataType === 'value', staleTime: 60 * 60 * 1000, // 1 hour gcTime: 60 * 60 * 1000, // 1 hour - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, }) const balanceChart = useQuery({ @@ -134,18 +134,33 @@ function AssetChart({ const body = await response.json() return body.result.data.json }, + enabled: activeDataType === 'balance' || activeDataType === 'value', staleTime: 60 * 60 * 1000, // 1 hour gcTime: 60 * 60 * 1000, // 1 hour - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, }) - if (priceChart.isLoading || balanceChart.isLoading) { + const valueChartData = useValueChartData({ + activeDataType, + priceData: priceChart.data, + balanceData: balanceChart.data, + }) + + const isLoading = + (activeDataType === 'price' && priceChart.isLoading) || + (activeDataType === 'balance' && balanceChart.isLoading) || + (activeDataType === 'value' && + (priceChart.isLoading || balanceChart.isLoading)) + + if (isLoading) { return } - if (priceChart.error || balanceChart.error) { + const hasError = + (activeDataType === 'price' && priceChart.error) || + (activeDataType === 'balance' && balanceChart.error) || + (activeDataType === 'value' && (priceChart.error || balanceChart.error)) + + if (hasError) { return (
@@ -160,6 +175,7 @@ function AssetChart({ diff --git a/apps/wallet/src/routes/portfolio/assets/-components/token.tsx b/apps/wallet/src/routes/portfolio/assets/-components/token.tsx index c3bdd6e7f..7a01ba2d1 100644 --- a/apps/wallet/src/routes/portfolio/assets/-components/token.tsx +++ b/apps/wallet/src/routes/portfolio/assets/-components/token.tsx @@ -1,6 +1,11 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' -import { Button, SegmentedControl, Tooltip } from '@status-im/components' +import { + Button, + SegmentedControl, + Tooltip, + useToast, +} from '@status-im/components' import { ArrowLeftIcon, BuyIcon, @@ -25,14 +30,17 @@ import { TokenLogo, TokenSkeleton, } from '@status-im/wallet/components' +import { ERROR_MESSAGES } from '@status-im/wallet/constants' import { useCopyToClipboard } from '@status-im/wallet/hooks' import { useQuery } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' import { cx } from 'class-variance-authority' +import { Interface, parseUnits } from 'ethers' import { useEthBalance } from '@/hooks/use-eth-balance' import { renderMarkdown } from '@/lib/markdown' import { apiClient } from '@/providers/api-client' +import { usePendingTransactions } from '@/providers/pending-transactions-context' import { useWallet } from '@/providers/wallet-context' import { AssetChart } from './asset-chart' @@ -52,6 +60,8 @@ type Props = { const NETWORKS = ['ethereum'] as const +const erc20 = new Interface(['function transfer(address to, uint256 amount)']) + function matchesAsset(asset: AssetData, ticker: string): boolean { if (ticker.startsWith('0x')) { return ( @@ -74,12 +84,21 @@ const Token = (props: Props) => { const [activeTimeFrame, setActiveTimeFrame] = useState(DEFAULT_TIME_FRAME) const { currentWallet } = useWallet() + const { addPendingTransaction } = usePendingTransactions() const [gasInput, setGasInput] = useState<{ to: string value: string } | null>(null) + const gasEstimateTimeoutRef = useRef(null) + + useEffect(() => { + setActiveDataType(DEFAULT_DATA_TYPE) + setActiveTimeFrame(DEFAULT_TIME_FRAME) + }, [ticker]) + + const toast = useToast() - const { data } = useQuery({ + const { data, isError: hasErrorFetchingAssets } = useQuery({ queryKey: ['assets', address], queryFn: async () => { const url = new URL( @@ -112,14 +131,22 @@ const Token = (props: Props) => { enabled: !!address, staleTime: 15 * 1000, gcTime: 60 * 60 * 1000, - refetchOnMount: true, - refetchOnWindowFocus: true, - refetchOnReconnect: true, }) + // Show error toast if fetching assets fails + useEffect(() => { + if (hasErrorFetchingAssets) { + toast.negative(ERROR_MESSAGES.ASSETS_FETCH) + } + }, [hasErrorFetchingAssets, toast]) + const asset = data?.assets?.find((a: AssetData) => matchesAsset(a, ticker)) - const { data: tokenDetail, isLoading: isTokenLoading } = useQuery({ + const { + data: tokenDetail, + isLoading: isTokenLoading, + isError: hasErrorFetchingToken, + } = useQuery({ queryKey: ['token', ticker], queryFn: async () => { const endpoint = ticker.startsWith('0x') @@ -158,11 +185,15 @@ const Token = (props: Props) => { enabled: !!asset, staleTime: 15 * 1000, gcTime: 60 * 60 * 1000, - refetchOnMount: true, - refetchOnWindowFocus: true, - refetchOnReconnect: true, }) + // Show error toast if fetching token detail fails + useEffect(() => { + if (hasErrorFetchingToken) { + toast.negative(ERROR_MESSAGES.TOKEN_INFO) + } + }, [hasErrorFetchingToken, toast]) + const isLoading = !data?.assets || isTokenLoading || !tokenDetail const needsEthBalance = tokenDetail?.summary.symbol !== 'ETH' @@ -177,8 +208,35 @@ const Token = (props: Props) => { const gasFeeQuery = useQuery({ queryKey: ['gas-fees', address, gasInput?.to, gasInput?.value], queryFn: async ({ queryKey }) => { + const isNative = tokenDetail?.summary.symbol === 'ETH' const [, from, to, value] = queryKey as [string, string, string, string] + let params + + if (isNative) { + params = { + from, + to, + value, + } + } else { + const contract = tokenDetail?.summary.contracts.ethereum + if (!contract) { + throw new Error('Contract address not found') + } + + const amount = BigInt(value) + + const data = erc20.encodeFunctionData('transfer', [to, amount]) + + params = { + from, + to: contract, + value: '0x0', + data, + } + } + const url = new URL( `${import.meta.env.WXT_STATUS_API_URL}/api/trpc/nodes.getFeeRate`, ) @@ -188,7 +246,7 @@ const Token = (props: Props) => { JSON.stringify({ json: { network: 'ethereum', - params: { from, to, value }, + params, }, }), ) @@ -203,13 +261,31 @@ const Token = (props: Props) => { const body = await response.json() return body.result.data.json }, - enabled: !!gasInput?.to && !!gasInput?.value && !!address, - refetchOnMount: false, - refetchOnWindowFocus: false, + enabled: !!gasInput?.to && !!gasInput?.value && !!address && !!tokenDetail, }) + // Show error toast if fetching gas fees fails + useEffect(() => { + if (gasFeeQuery.isError) { + toast.negative(ERROR_MESSAGES.GAS_FEES_FETCH) + } + }, [gasFeeQuery.isError, toast]) + + useEffect(() => { + return () => { + if (gasEstimateTimeoutRef.current) { + clearTimeout(gasEstimateTimeoutRef.current) + } + } + }, []) const prepareGasEstimate = (to: string, value: string) => { - setGasInput({ to, value }) + if (gasEstimateTimeoutRef.current) { + clearTimeout(gasEstimateTimeoutRef.current) + } + + gasEstimateTimeoutRef.current = setTimeout(() => { + setGasInput({ to, value }) + }, 500) } const handleOpenTab = (url: string) => { @@ -269,12 +345,13 @@ const Token = (props: Props) => { symbol: tokenDetail.summary.symbol, ethBalance, network: (Object.keys(tokenDetail.assets)[0] ?? 'ethereum') as NetworkType, + decimals: asset.decimals ?? 18, } // Mock wallet data. Replace with actual wallet data from the user's account. const account: Account = { address, - name: 'Account 1', + name: currentWallet?.name || 'Account 1', emoji: '🍑', color: 'magenta', } @@ -282,27 +359,117 @@ const Token = (props: Props) => { const signTransaction = async ( formData: SendAssetsFormData & { password: string }, ) => { - const amountHex = BigInt( - Math.floor(parseFloat(formData.amount) * 1e18), - ).toString(16) - - const result = await apiClient.wallet.account.ethereum.send.mutate({ - amount: amountHex, - toAddress: formData.to, - fromAddress: address, - password: formData.password, - walletId: currentWallet?.id || '', - gasLimit: gasFeeQuery.data.txParams.gasLimit.replace(/^0x/, ''), - maxFeePerGas: gasFeeQuery.data.txParams.maxFeePerGas.replace(/^0x/, ''), - maxInclusionFeePerGas: - gasFeeQuery.data.txParams.maxPriorityFeePerGas.replace(/^0x/, ''), - }) - - if (!result.id || !result.id.txid) { - throw new Error('Transaction failed') - } + const isNative = tokenDetail?.summary.symbol === 'ETH' + + if (isNative) { + const amountHex = parseUnits(formData.amount, 18).toString(16) + const params = { + amount: amountHex, + toAddress: formData.to, + fromAddress: address, + password: formData.password, + walletId: currentWallet?.id || '', + gasLimit: gasFeeQuery.data.txParams.gasLimit, + maxFeePerGas: gasFeeQuery.data.txParams.maxFeePerGas, + maxInclusionFeePerGas: gasFeeQuery.data.txParams.maxPriorityFeePerGas, + } + + const result = await apiClient.wallet.account.ethereum.send.mutate(params) + if (!result.id || result.id.txid?.error) { + console.error(result.id.txid?.error) + toast.negative(ERROR_MESSAGES.TX_FAILED) + throw new Error('Transaction failed') + } + + const txHash = typeof result.id === 'string' ? result.id : result.id.txid + + if (!txHash) { + toast.negative(ERROR_MESSAGES.TX_FAILED) + throw new Error('Transaction hash not found') + } - return result.id.txid + addPendingTransaction({ + hash: txHash, + from: address, + to: formData.to, + value: parseFloat(formData.amount), + asset: tokenDetail.summary.symbol, + network: 'ethereum', + status: 'pending', + category: 'external', + blockNum: '0', + metadata: { + blockTimestamp: new Date().toISOString(), + }, + rawContract: { + value: amountHex, + address: ticker.startsWith('0x') ? ticker : null, + decimal: '18', + }, + eurRate: 0, + }) + } else { + const tokenDecimals = asset.decimals ?? 18 + const amount = parseUnits(formData.amount, tokenDecimals) + const amountHex = amount.toString(16) + const contractAddress = tokenDetail?.summary.contracts.ethereum + + if (!contractAddress) { + throw new Error('Token contract address not found') + } + + const data = erc20.encodeFunctionData('transfer', [formData.to, amount]) + + const params = { + amount: '0', + toAddress: contractAddress, + fromAddress: address, + password: formData.password, + walletId: currentWallet?.id || '', + gasLimit: gasFeeQuery.data.txParams.gasLimit, + maxFeePerGas: gasFeeQuery.data.txParams.maxFeePerGas, + maxInclusionFeePerGas: gasFeeQuery.data.txParams.maxPriorityFeePerGas, + data, + } + + const result = + await apiClient.wallet.account.ethereum.sendErc20.mutate(params) + + if (!result.id || result.id.txid?.error) { + console.error(result.id.txid?.error) + toast.negative(ERROR_MESSAGES.TX_FAILED) + throw new Error('Transaction failed') + } + + const txHash = typeof result.id === 'string' ? result.id : result.id.txid + + if (!txHash) { + toast.negative(ERROR_MESSAGES.TX_FAILED) + throw new Error('Transaction hash not found') + } + + addPendingTransaction({ + hash: txHash, + from: address, + to: formData.to, + value: parseFloat(formData.amount), + asset: tokenDetail.summary.symbol, + network: 'ethereum', + status: 'pending', + category: 'external', + blockNum: '0', + metadata: { + blockTimestamp: new Date().toISOString(), + }, + rawContract: { + value: amountHex, + address: ticker.startsWith('0x') ? ticker : null, + decimal: asset.decimals?.toString() ?? '18', + }, + eurRate: 0, + }) + return result.id.txid + } } const verifyPassword = async (inputPassword: string): Promise => { @@ -332,9 +499,13 @@ const Token = (props: Props) => { } rightSlot={
- + @@ -358,7 +529,7 @@ const Token = (props: Props) => { isLoadingFees={gasFeeQuery.isFetching} onEstimateGas={prepareGasEstimate} > - @@ -381,9 +552,13 @@ const Token = (props: Props) => {
- + @@ -423,7 +598,7 @@ const Token = (props: Props) => { onValueChange={value => setActiveDataType(value as ChartDataType) } - size="24" + size="32" > Price @@ -431,6 +606,9 @@ const Token = (props: Props) => { Balance + {/* + Value + */}
@@ -439,7 +617,7 @@ const Token = (props: Props) => { onValueChange={value => setActiveTimeFrame(value as ChartTimeFrame) } - size="24" + size="32" > {TIME_FRAMES.map(frame => ( @@ -524,7 +702,7 @@ const Token = (props: Props) => { tooltip: ( ), }, @@ -539,17 +717,20 @@ const Token = (props: Props) => { tooltip: ( ), }, { label: '24h Volume', value: ( - + ), tooltip: ( - + ), }, { @@ -568,7 +749,7 @@ const Token = (props: Props) => { index < 4 && '2xl:border-b', )} > - +
{item.label} @@ -577,7 +758,7 @@ const Token = (props: Props) => { {item.value}
-
+
))}
@@ -590,4 +771,21 @@ const Token = (props: Props) => { ) } +const OptionalTooltip = ({ + content, + children, +}: { + content?: React.ReactNode + children: React.ReactElement +}) => { + if (!content) { + return children + } + return ( + + {children} + + ) +} + export { Token } diff --git a/apps/wallet/src/routes/portfolio/assets/index.tsx b/apps/wallet/src/routes/portfolio/assets/index.tsx index 988a0ce2e..0d73d79ad 100644 --- a/apps/wallet/src/routes/portfolio/assets/index.tsx +++ b/apps/wallet/src/routes/portfolio/assets/index.tsx @@ -1,9 +1,13 @@ +import { useEffect } from 'react' + +import { useToast } from '@status-im/components' import { AssetsList, AssetsListLoading, - FeedbackSection, + EmptyStateActions, PinExtension, } from '@status-im/wallet/components' +import { ERROR_MESSAGES } from '@status-im/wallet/constants' import { createFileRoute, useRouter } from '@tanstack/react-router' import SplittedLayout from '@/components/splitted-layout' @@ -21,15 +25,24 @@ function Component() { const { currentWallet, isLoading: isWalletLoading } = useWallet() const { isPinExtension, handleClose } = usePinExtension() - const address = currentWallet?.activeAccounts[0].address + const toast = useToast() + + const address = currentWallet?.activeAccounts?.[0]?.address const router = useRouter() - const { data, isLoading } = useAssets({ + const { data, isLoading, isError } = useAssets({ address, isWalletLoading, }) const isDesktop = useMediaQuery('xl') + // Show error toast if there is an error fetching assets + useEffect(() => { + if (isError) { + toast.negative(ERROR_MESSAGES.ASSETS_FETCH) + } + }, [isError, toast]) + if (!currentWallet || !address) return null return ( @@ -39,15 +52,22 @@ function Component() { { - const ticker = url.split('/').pop() - if (!ticker) return - router.navigate({ - to: '/portfolio/assets/$ticker', - params: { ticker }, - ...(!isDesktop && { - viewTransition: true, - }), - }) + try { + const ticker = url.split('/').pop() + if (!ticker) { + console.error('Invalid ticker from URL:', url) + return + } + router.navigate({ + to: '/portfolio/assets/$ticker', + params: { ticker }, + ...(!isDesktop && { + viewTransition: true, + }), + }) + } catch (error) { + console.error('Navigation error:', error) + } }} clearSearch={() => { console.log('Search cleared') @@ -56,12 +76,12 @@ function Component() { pathname="/portfolio/assets" /> } - detail={} + detail={} loadingState={} isLoading={isLoading} /> {isPinExtension && ( -
+
)} diff --git a/apps/wallet/src/routes/portfolio/collectibles/$network/$contract/$id.tsx b/apps/wallet/src/routes/portfolio/collectibles/$network/$contract/$id.tsx index 3223c858f..14ecdcdea 100644 --- a/apps/wallet/src/routes/portfolio/collectibles/$network/$contract/$id.tsx +++ b/apps/wallet/src/routes/portfolio/collectibles/$network/$contract/$id.tsx @@ -1,12 +1,15 @@ -import { Suspense } from 'react' +import { useEffect, useMemo } from 'react' +import { useToast } from '@status-im/components' import { CollectiblesGrid as CollectiblesList, CollectiblesGridSkeleton, } from '@status-im/wallet/components' +import { ERROR_MESSAGES } from '@status-im/wallet/constants' import { createFileRoute, useRouterState } from '@tanstack/react-router' import SplittedLayout from '@/components/splitted-layout' +import { useCollectibles } from '@/hooks/use-collectibles' import { useWallet } from '../../../../../providers/wallet-context' import { Collectible } from '../../-components/collectible' @@ -27,22 +30,53 @@ function Component() { const params = Route.useParams() const { network, contract, id } = params + const toast = useToast() + const searchParams = new URLSearchParams(window.location.search) const search = searchParams.get('search') ?? undefined const pathname = routerState.location.pathname const address = currentWallet?.activeAccounts[0].address - const { data, fetchNextPage, isFetchingNextPage, hasNextPage, isLoading } = - useCollectibles({ - address, - isWalletLoading, - }) + const { + data, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + isLoading, + isError, + isFetchNextPageError, + isFetchPreviousPageError, + } = useCollectibles({ + address, + isWalletLoading, + }) const collectibles = useMemo(() => { return data?.pages.flatMap(page => page.collectibles ?? []) ?? [] }, [data?.pages]) + // Show error toast if there is an error fetching collectibles + useEffect(() => { + if (isError) { + toast.negative(ERROR_MESSAGES.COLLECTIBLES) + } + }, [isError, toast]) + + // Show error toast if there is an error fetching next page of collectibles + useEffect(() => { + if (isFetchNextPageError) { + toast.negative(ERROR_MESSAGES.COLLECTIBLES_PARTIAL) + } + }, [isFetchNextPageError, toast]) + + // Show error toast if there is an error fetching previous page of collectibles + useEffect(() => { + if (isFetchPreviousPageError) { + toast.negative(ERROR_MESSAGES.COLLECTIBLES_PARTIAL) + } + }, [isFetchPreviousPageError, toast]) + if (!currentWallet || !address) { return null } @@ -71,13 +105,11 @@ function Component() { loadingState={} isLoading={isLoading} detail={ - Loading collectible...

}> - -
+ } />
diff --git a/apps/wallet/src/routes/portfolio/collectibles/-components/card-detail.tsx b/apps/wallet/src/routes/portfolio/collectibles/-components/card-detail.tsx new file mode 100644 index 000000000..583020e66 --- /dev/null +++ b/apps/wallet/src/routes/portfolio/collectibles/-components/card-detail.tsx @@ -0,0 +1,39 @@ +import { ExternalIcon } from '@status-im/icons/20' + +type Props = { + title: string + children: React.ReactNode + href?: string +} + +const CardDetail = (props: Props) => { + const { title, children } = props + + const isLink = Boolean(props.href) + + const Element = isLink ? 'a' : 'div' + const elementProps = isLink + ? { + href: props.href, + target: '_blank', + rel: 'noopener noreferrer', + } + : {} + + return ( + +
+ {title} +
+ {isLink && ( + + )} + {children} +
+ ) +} + +export { CardDetail } diff --git a/apps/wallet/src/routes/portfolio/collectibles/-components/collectible.tsx b/apps/wallet/src/routes/portfolio/collectibles/-components/collectible.tsx index 1b6e75ad0..a07d0a118 100644 --- a/apps/wallet/src/routes/portfolio/collectibles/-components/collectible.tsx +++ b/apps/wallet/src/routes/portfolio/collectibles/-components/collectible.tsx @@ -1,19 +1,24 @@ -import { Button } from '@status-im/components' +import { useEffect } from 'react' + +import { Button, useToast } from '@status-im/components' import { ArrowLeftIcon, ExternalIcon, - OptionsIcon, + // OptionsIcon, SadIcon, } from '@status-im/icons/20' import { OpenseaIcon } from '@status-im/icons/social' import { CollectibleSkeleton, - CurrencyAmount, + // CurrencyAmount, NetworkLogo, } from '@status-im/wallet/components' +import { ERROR_MESSAGES } from '@status-im/wallet/constants' import { useQuery } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' +import { CardDetail } from './card-detail' + import type { NetworkType } from '@status-im/wallet/data' type Props = { @@ -25,7 +30,13 @@ type Props = { const Collectible = (props: Props) => { const { network, contract, id } = props - const { data: collectible, isLoading } = useQuery({ + const toast = useToast() + + const { + data: collectible, + isLoading, + isError, + } = useQuery({ queryKey: ['collectible', network, contract, id], queryFn: async () => { const url = new URL( @@ -50,7 +61,7 @@ const Collectible = (props: Props) => { }) if (!response.ok) { - throw new Error('Failed to fetch.') + throw new Error(response.statusText, { cause: response.status }) } const body = await response.json() @@ -58,20 +69,24 @@ const Collectible = (props: Props) => { }, staleTime: 15 * 1000, // 15 seconds gcTime: 60 * 60 * 1000, // 1 hour - refetchOnMount: true, - refetchOnWindowFocus: true, - refetchOnReconnect: true, }) + // Show error toast if fetching fails + useEffect(() => { + if (isError) { + toast.negative(ERROR_MESSAGES.COLLECTIBLE_INFO) + } + }, [isError, toast]) + if (isLoading || !collectible) { return } - const imageUrl = collectible.image || collectible.thumbnail + const imageUrl = collectible.thumbnail || collectible.image const imageAlt = collectible.name || 'Collectible image' return ( -
+
{
-
+
{collectible.name}
- {collectible.floor_price && collectible.price_eur && ( + {/* {collectible.floor_price && collectible.price_eur && (
@@ -113,7 +128,7 @@ const Collectible = (props: Props) => {
- )} + )} */}
-
@@ -158,52 +173,83 @@ const Collectible = (props: Props) => {
{collectible.about}
-
-
- - {collectible.network} -
+
+ +
+ +
{collectible.network}
+
+
{collectible.standard !== 'NOT_A_CONTRACT' && ( <> -
{collectible.contract}
-
{collectible.standard}
+ +
+ {truncateAddress(collectible.contract)} +
+
+ +
{collectible.standard}
+
)} {collectible.collection.size && ( -
{collectible.collection.size}
+ +
{collectible.collection.size}
+
)}
+ -