diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml new file mode 100644 index 00000000..cc3e5527 --- /dev/null +++ b/.github/workflows/builds.yml @@ -0,0 +1,45 @@ +name: Builds +on: + - pull_request + +jobs: + builds: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + cache: 'pnpm' + node-version-file: '.tool-versions' + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Set environment variables + run: | + grep '^export ' ./apps/staking/scripts/mock-build-env.sh | sed 's/export //' >> $GITHUB_ENV + + - name: Run builds + run: pnpm build diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml new file mode 100644 index 00000000..a01da3e1 --- /dev/null +++ b/.github/workflows/linting.yml @@ -0,0 +1,42 @@ +name: Linting +on: + - push + - pull_request + +jobs: + linting: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + cache: 'pnpm' + node-version-file: '.tool-versions' + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Run linter + run: pnpm lint diff --git a/.github/workflows/types.yml b/.github/workflows/types.yml new file mode 100644 index 00000000..19aab1db --- /dev/null +++ b/.github/workflows/types.yml @@ -0,0 +1,42 @@ +name: Type Checks +on: + - push + - pull_request + +jobs: + type-checks: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + cache: 'pnpm' + node-version-file: '.tool-versions' + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Run type checks + run: pnpm check-types diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..373f8651 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,83 @@ +# Contributor Guidelines + +## Advice for new contributors + +Start small. The PRs most likely to be merged are the ones that make small, easily reviewed changes with clear and +specific intentions. + +It's a good idea to gauge interest in your intended work by finding or creating +a [GitHub Issue](https://github.com/oxen-io/websites/issues) for it. + +You're most likely to have your pull request accepted if it addresses an +existing [GitHub Issue](https://github.com/oxen-io/websites/issues) marked with +the [good-first-issue](https://github.com/oxen-io/websites/labels/good%20first%20issue) +tag. + +Of course, we encourage community developers to work on ANY issue, regardless of how it’s tagged, however, if you pick +up or create an issue without the “Good first issue” tag it would be best if you leave a comment on the issue so that +the team can give you any guidance required, especially around UI heavy features. + +## Developer Tips + +See the development section of the [README.md](README.md#development) in the root of the repository. And please read the +`README.md` of any app or package you are interested in contributing to. + +## Tests + +Please write tests! Each app and package has a `README.md` file that explains how to write and run tests for that app or +package. + +You can run all tests at once with the following command: + +```shell +pnpm test +``` + +## GitHub Actions + +You can mock all the GitHub actions by running the following command: + +```shell +pnpm gh +``` + +This will run the linting, type checking, unit tests, and build scripts for all apps and packages. + +## Committing your changes + +Before a commit is accepted the staged changes will be formatted using [prettier](https://prettier.io/) and linted +using [eslint](https://eslint.org/). + +### Commit Message Convention + +This project follows [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) + +Commit messages will be checked using [husky](https://typicode.github.io/husky/#/) +and [commitlint](https://commitlint.js.org/). + +## Pull requests + +So you want to make a pull request? Please observe the following guidelines. + +- First, make sure that your `pnpm test` and `pnpm build` run passes - it's very similar to what our Continuous + Integration servers do to test the app. +- Be sure to add and run tests! +- [Rebase](https://nathanleclaire.com/blog/2014/09/14/dont-be-scared-of-git-rebase/) your changes on the latest `dev` + branch, resolving any conflicts. This ensures that your changes will merge cleanly when you open your PR. +- Make sure the diff between `dev` and your branch contains only the minimal set of changes needed to implement your + feature or bugfix. This will make it easier for the person reviewing your code to approve the changes. Please do not + submit a PR with commented out code or unfinished features. +- Avoid meaningless or too-granular commits. If your branch contains commits like the lines of "Oops, reverted this + change" or "Just experimenting, will delete this later", + please [squash or rebase those changes away](https://robots.thoughtbot.com/git-interactive-rebase-squash-amend-rewriting-history). +- Don't have too few commits. If you have a complicated or long-lived feature branch, it may make sense to break the + changes up into logical atomic chunks to aid in the review process. +- Provide a well written and nicely formatted commit message. + See [this blog post](http://chris.beams.io/posts/git-commit/) for some tips on formatting. As far as content, try to + include in your summary + 1. What you changed + 2. Why this change was made (including git issue # if appropriate) + 3. Any relevant technical details or motivations for your implementation choices that may be helpful to someone + reviewing or auditing the commit history in the future. When in doubt, err on the side of a longer commit + message. Above all, spend some time with the repository. Follow the pull request template added to your pull + request description automatically. Take a look at recent approved pull requests, see how they did things. diff --git a/README.md b/README.md index 59e8deec..d6b10a39 100644 --- a/README.md +++ b/README.md @@ -4,31 +4,53 @@ Session Websites is a collection of websites and libraries for the Session Web e ## Apps and Packages -This repository is a monorepo that contains multiple apps and packages. Each app and package is located in its own directory. +This repository is a monorepo that contains multiple apps and packages. Each app and package is located in its own +directory. - `apps` directory contains all the apps. - `packages` directory contains all the packages. -An app is a standalone application that can be run independently. A package is a library that can be used by other apps or packages. +An app is a standalone application that can be run independently. A package is a library that can be used by other apps +or packages. This repository contains the following apps and packages: -- `staking`: Session Staking [Next.js](https://nextjs.org/) app. -- `@session/ui`: Session UI component library. -- `@session/eslint-config`: `eslint` configurations. -- `@session/typescript-config`: `tsconfig.json` configurations. -- `@session/contracts`: Session smart contract js library for interacting with the Session smart contracts. -- `@session/sent-staking-js`: Session Token Staking js library for interacting with the Session Token staking backend. -- `@session/wallet`: A wallet library for interacting with the Session Token. -- `@session/util`: A utility library for common functions. -- `@session/testing`: A testing library. +### Apps + +- `staking`: Session Staking [Next.js](https://nextjs.org/) app. [Read more](apps/staking/README.md). + +### Packages + +- `@session/auth`: Auth package for handling third-party authentication + using [NextAuth.js](https://next-auth.js.org/). [Read more](packages/auth/README.md). +- `@session/contracts`: Session smart contract js library for interacting with the Session smart + contracts. [Read more](packages/contracts/README.md). +- `@session/eslint-config`: `eslint` + configurations. [Read more](packages/eslint-config/README.md). [Read more](packages/eslint-config/README.md). +- `@session/feture-flags`: Feature flags library for [Next.js](https://nextjs.org/) apps. Supporting client, server, and + remote flags. [Read more](packages/feature-flags/README.md). +- `@session/logger`: An opinionated logging wrapper. [Read more](packages/logger/README.md). +- `@session/sent-staking-js`: Session Token Staking js library for interacting with the Session Token staking + backend. [Read more](packages/sent-staking-js/README.md). +- `@session/testing`: A testing utility library. [Read more](packages/testing/README.md). +- `@session/typescript-config`: `tsconfig.json` configurations. [Read more](packages/typescript-config/README.md). +- `@session/ui`: Session UI component library is a collection of UI components for [Next.js](https://nextjs.org/) apps + and uses + [Tailwind CSS](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), + and [shadcn-ui](https://ui.shadcn.com/). [Read more](packages/ui/README.md). +- `@session/util`: A utility library for common functions. [Read more](packages/util/README.md). +- `@session/wallet`: A wallet library for interacting with the Session Token. [Read more](packages/wallet/README.md). ### Utilities -- [TypeScript](https://www.typescriptlang.org/) for static type checking -- [ESLint](https://eslint.org/) for code linting -- [Prettier](https://prettier.io) for code formatting -- [Tailwind CSS](https://tailwindcss.com/) for styling +- [TypeScript](https://www.typescriptlang.org/) for static type checking. +- [ESLint](https://eslint.org/) for code linting. +- [Prettier](https://prettier.io) for code formatting. +- [Tailwind CSS](https://tailwindcss.com/) for styling. +- [Next.js](https://nextjs.org/) for server-side rendering. +- [shadcn-ui](https://ui.shadcn.com/) for UI components. +- [Radix UI](https://www.radix-ui.com/) for UI components. +- [Jest](https://jestjs.io/) for unit testing. ### Build @@ -44,7 +66,11 @@ pnpm build - [pnpm](https://pnpm.io/) (v9 or higher) - [jq](https://jqlang.github.io/jq/) (see [jq for mac](https://formulae.brew.sh/formula/jq)) -We recommend using a node version manager like [asdf](https://asdf-vm.com/) to manage your node versions. The `.tool-versions` file in the root of the project specifies the node version to use. We also have an `.nvmrc` file that specifies the same node version to use. You can enable support for [Using Existing Tool Version Files](https://asdf-vm.com/guide/getting-started.html#using-existing-tool-version-files) in asdf to use these files. +We recommend using a node version manager like [asdf](https://asdf-vm.com/) to manage your node versions. The +`.tool-versions` file in the root of the project specifies the node version to use. We also have an `.nvmrc` file that +specifies the same node version to use. You can enable support +for [Using Existing Tool Version Files](https://asdf-vm.com/guide/getting-started.html#using-existing-tool-version-files) +in asdf to use these files. ## Getting Started @@ -56,6 +82,44 @@ pnpm install This will install all the dependencies for all the apps and packages. +## Contributing + +We welcome contributions to the Session Web Ecosystem. Please read our [contributing guidelines](CONTRIBUTING.md) for +more information on how to contribute. + ## Development -You can find a `README.md` file in each app and package directory that explains how to develop and test that specific app or package. +You can find a `README.md` file in each app and package directory that explains how to develop and test that specific +app or package. + +### Developer Telemetry + +Some tools used in this repo have anonymous developer telemetry enabled by default. This is developer telemetry that +tools creators use to report usage from developers and does not apply to any apps created using these +tools. We +have disabled all telemetry, you can ensure developer telemetry is disabled in all packages by running +`pnpm check-telemetry`. We have disabled telemetry by aliasing the `turbo` command in the repository root with +`NEXT_TELEMETRY_DISABLED=1 DO_NOT_TRACK=1`. + +- `NEXT_TELEMETRY_DISABLED=1` disables developer telemetry in nextjs. +- `DO_NOT_TRACK` disables telemetry in all packages that respect + the [Console Do Not Track (DNT) standard](https://consoledonottrack.com/) + +## Testing + +Our testing suite is a work in progress and any contributions are welcome. + +### Jest + +We use [Jest](https://jestjs.io/) for unit testing. You can run the tests with the following command: + +```sh +pnpm test +``` + +### BrowserStack + +This project is tested with BrowserStack. + +[BrowserStack](https://browserstack.com/) is used for cross-browser, accessibility, and regression testing. + diff --git a/apps/staking/README.md b/apps/staking/README.md new file mode 100644 index 00000000..319b5ca5 --- /dev/null +++ b/apps/staking/README.md @@ -0,0 +1,53 @@ +# Session Staking + +Session Staking is a [Next.js](https://nextjs.org/) app for managing and staking +to [Session Nodes](https://github.com/oxen-io/oxen-core). + +## Getting Started + +You can follow the generic instructions in the root [README.md](../../README.md#getting-started) to get started. + +## Development + +Running the app requires several environment variables to be set. See the [.env.local.template](.env.local.template) +file for a list +of required variables. + +We recommend running the [Session Token Staking Backend](https://github.com/oxen-io/sent-staking-backend/) and +a [Session Node](https://github.com/oxen-io/oxen-core) yourself to ensure any changes you make will work +with the latest changes. + +### Session Node + +You'll need a [Session Node](https://github.com/oxen-io/oxen-core) to make RPC calls to, this node +does not need to be staked to and does not need to join the network as a participating node. You'll need to run the +following to start the node: + +```shell +oxend --stagenet --lmq-public tcp://127.0.0.1: --l2-provider https://sepolia-rollup.arbitrum.io/rpc +``` + +You can then set the `NEXT_PUBLIC_SENT_EXPLORER_API_URL` environment variable in your `.env.local` file to point to +the Session Node's RPC endpoint (`tcp://127.0.0.1:` in the example above). + +**Note:** You can use any available port for the node RPC endpoint, just make sure it's consistent in all places. + +### Session Token Staking Backend + +Set up the [Session Token Staking Backend](https://github.com/oxen-io/sent-staking-backend/) by following the +instructions in the [README.md](https://github.com/oxen-io/sent-staking-backend/blob/main/README.md). + +Make sure the `config.py` file in the backend directory has the following values: + +```python +stagenet_rpc = 'tcp://127.0.0.1:' +``` + +You can then run the backend with the following command: + +```shell +uwsgi --http 127.0.0.1:5000 --master -p 4 -w sent --callable app --fs-reload sent.py +``` + +You can then set the `NEXT_PUBLIC_SENT_STAKING_BACKEND_URL` environment variable in your `.env.local` file to point to +the Session Token Staking Backend's RPC endpoint (`http://127.0.0.1:5000` in the example above). \ No newline at end of file diff --git a/apps/staking/app/faucet/AuthModule.tsx b/apps/staking/app/faucet/AuthModule.tsx index 9c5d23b1..e8b34502 100644 --- a/apps/staking/app/faucet/AuthModule.tsx +++ b/apps/staking/app/faucet/AuthModule.tsx @@ -15,7 +15,7 @@ import { ButtonDataTestId } from '@/testing/data-test-ids'; import { zodResolver } from '@hookform/resolvers/zod'; import { CHAIN } from '@session/contracts'; import { ArrowDownIcon } from '@session/ui/icons/ArrowDownIcon'; -import { toast } from '@session/ui/lib/sonner'; +import { toast } from '@session/ui/lib/toast'; import { Form, FormControl, diff --git a/apps/staking/app/faucet/FaucetModule.tsx b/apps/staking/app/faucet/FaucetModule.tsx index 29687c03..5010a7d2 100644 --- a/apps/staking/app/faucet/FaucetModule.tsx +++ b/apps/staking/app/faucet/FaucetModule.tsx @@ -18,7 +18,7 @@ import { WalletAddTokenWithLocales } from '@/components/WalletAddTokenWithLocale import { WalletModalButtonWithLocales } from '@/components/WalletModalButtonWithLocales'; import { URL } from '@/lib/constants'; import { ButtonDataTestId } from '@/testing/data-test-ids'; -import { toast } from '@session/ui/lib/sonner'; +import { toast } from '@session/ui/lib/toast'; import { Form, FormControl, diff --git a/apps/staking/app/faucet/actions.ts b/apps/staking/app/faucet/actions.ts index 877a69d8..f79452bf 100644 --- a/apps/staking/app/faucet/actions.ts +++ b/apps/staking/app/faucet/actions.ts @@ -1,14 +1,7 @@ 'use server'; import { COMMUNITY_DATE, FAUCET, FAUCET_ERROR, TICKER } from '@/lib/constants'; -import { - addresses, - CHAIN, - chains, - formatSENT, - SENT_DECIMALS, - SENT_SYMBOL, -} from '@session/contracts'; +import { addresses, CHAIN, chains, SENT_DECIMALS, SENT_SYMBOL } from '@session/contracts'; import { SENTAbi } from '@session/contracts/abis'; import { ETH_DECIMALS } from '@session/wallet/lib/eth'; import { createPublicWalletClient, createServerWallet } from '@session/wallet/lib/server-wallet'; @@ -25,6 +18,7 @@ import { TABLE, TransactionHistory, } from './utils'; +import { formatSENTBigInt } from '@session/contracts/hooks/SENT'; class FaucetError extends Error { faucetError: FAUCET_ERROR; @@ -186,7 +180,7 @@ export async function transferTestTokens({ */ if (faucetTokenBalance < faucetTokenWarning) { console.warn( - `Faucet wallet ${SENT_SYMBOL} balance (${formatSENT(faucetTokenBalance)} ${SENT_SYMBOL}) is below the warning threshold (${formatSENT(faucetTokenWarning)})` + `Faucet wallet ${SENT_SYMBOL} balance (${formatSENTBigInt(faucetTokenBalance)}) is below the warning threshold (${formatSENTBigInt(faucetTokenWarning)})` ); } diff --git a/apps/staking/app/mystakes/modules/BalanceModule.tsx b/apps/staking/app/mystakes/modules/BalanceModule.tsx index 986e5d03..031beb9d 100644 --- a/apps/staking/app/mystakes/modules/BalanceModule.tsx +++ b/apps/staking/app/mystakes/modules/BalanceModule.tsx @@ -4,19 +4,18 @@ import { getVariableFontSizeForLargeModule, ModuleDynamicQueryText, } from '@/components/ModuleDynamic'; -import { getTotalStakedAmountForAddress } from '@/components/NodeCard'; +import { getTotalStakedAmountForAddressBigInt } from '@/components/NodeCard'; import type { ServiceNode } from '@session/sent-staking-js/client'; import { Module, ModuleTitle } from '@session/ui/components/Module'; import { useWallet } from '@session/wallet/hooks/wallet-hooks'; import { useTranslations } from 'next-intl'; import { useMemo } from 'react'; -import { Address } from 'viem'; +import type { Address } from 'viem'; import { useStakingBackendQueryWithParams } from '@/lib/sent-staking-backend-client'; import { getStakedNodes } from '@/lib/queries/getStakedNodes'; import { generateMockNodeData } from '@session/sent-staking-js/test'; import type { QUERY_STATUS } from '@/lib/query'; -import { formatSENTNumber } from '@session/contracts/hooks/SENT'; -import { DYNAMIC_MODULE } from '@/lib/constants'; +import { formatSENTBigInt } from '@session/contracts/hooks/SENT'; import { FEATURE_FLAG } from '@/lib/feature-flags'; import { useFeatureFlag } from '@/lib/feature-flags-client'; @@ -27,9 +26,11 @@ const getTotalStakedAmount = ({ nodes: Array; address: Address; }) => { - return nodes.reduce( - (acc, node) => acc + getTotalStakedAmountForAddress(node.contributors, address), - 0 + return formatSENTBigInt( + nodes.reduce( + (acc, node) => acc + getTotalStakedAmountForAddressBigInt(node.contributors, address), + BigInt(0) + ) ); }; @@ -75,10 +76,6 @@ export default function BalanceModule() { const titleFormat = useTranslations('modules.title'); const title = dictionary('title'); - const formattedTotalStakedAmount = useMemo(() => { - return `${formatSENTNumber(totalStakedAmount ?? 0, DYNAMIC_MODULE.SENT_ROUNDED_DECIMALS)}`; - }, [totalStakedAmount]); - return ( {titleFormat('format', { title })} @@ -94,10 +91,10 @@ export default function BalanceModule() { refetch, }} style={{ - fontSize: getVariableFontSizeForLargeModule(formattedTotalStakedAmount.length), + fontSize: getVariableFontSizeForLargeModule(totalStakedAmount?.length ?? 6), }} > - {formattedTotalStakedAmount} + {totalStakedAmount} ); diff --git a/apps/staking/app/mystakes/modules/ClaimTokensModule.tsx b/apps/staking/app/mystakes/modules/ClaimTokensModule.tsx index 6b817abd..5510665e 100644 --- a/apps/staking/app/mystakes/modules/ClaimTokensModule.tsx +++ b/apps/staking/app/mystakes/modules/ClaimTokensModule.tsx @@ -18,24 +18,30 @@ import { formatBigIntTokenValue } from '@session/util/maths'; import { ETH_DECIMALS } from '@session/wallet/lib/eth'; import { LoadingText } from '@session/ui/components/loading-text'; import { QUERY, TICKER, URL } from '@/lib/constants'; -import useClaimRewards, { CLAIM_REWARDS_STATE } from '@/hooks/useClaimRewards'; -import { type ReactNode, useEffect, useMemo } from 'react'; +import useClaimRewards from '@/hooks/useClaimRewards'; +import { useEffect, useMemo } from 'react'; import { useWallet } from '@session/wallet/hooks/wallet-hooks'; import { externalLink } from '@/lib/locale-defaults'; -import { TriangleAlertIcon } from '@session/ui/icons/TriangleAlertIcon'; -import { Tooltip } from '@session/ui/ui/tooltip'; +import { AlertTooltip } from '@session/ui/ui/tooltip'; import { useStakingBackendQueryWithParams } from '@/lib/sent-staking-backend-client'; import { getRewardsClaimSignature } from '@/lib/queries/getRewardsClaimSignature'; -import type { WriteContractStatus } from '@session/contracts/hooks/useContractWriteQuery'; -import type { VariantProps } from 'class-variance-authority'; -import { StatusIndicator, statusVariants } from '@session/ui/components/StatusIndicator'; import type { Address } from 'viem'; import { Loading } from '@session/ui/components/loading'; +import { useRemoteFeatureFlagQuery } from '@/lib/feature-flags-client'; +import { REMOTE_FEATURE_FLAG } from '@/lib/feature-flags'; +import { toast } from '@session/ui/lib/toast'; +import { ClaimRewardsDisabledInfo } from '@/components/ClaimRewardsDisabledInfo'; +import { Progress, PROGRESS_STATUS } from '@session/ui/components/motion/progress'; export default function ClaimTokensModule() { const { address } = useWallet(); const dictionary = useTranslations('modules.claim'); const { canClaim, unclaimedRewards, formattedUnclaimedRewardsAmount } = useUnclaimedTokens(); + const { enabled: isClaimRewardsDisabled, isLoading: isRemoteFlagLoading } = + useRemoteFeatureFlagQuery(REMOTE_FEATURE_FLAG.DISABLE_CLAIM_REWARDS); + + const isDisabled = + !(address && canClaim && unclaimedRewards) || isRemoteFlagLoading || isClaimRewardsDisabled; const { data: rewardsClaimData, @@ -45,12 +51,15 @@ export default function ClaimTokensModule() { getRewardsClaimSignature, { address: address! }, { - enabled: !!address, + enabled: !isDisabled, staleTime: QUERY.STALE_TIME_CLAIM_REWARDS, } ); const handleClick = () => { + if (!isRemoteFlagLoading && isClaimRewardsDisabled) { + toast.error(); + } if (!canClaim) return; if (isStale) { void refetch(); @@ -64,7 +73,6 @@ export default function ClaimTokensModule() { return [BigInt(amount), signature, non_signer_indices.map(BigInt)]; }, [rewardsClaimData]); - const isDisabled = !(address && canClaim && unclaimedRewards); const isReady = !!(!isDisabled && rewards && excludedSigners && blsSignature); return ( @@ -76,10 +84,10 @@ export default function ClaimTokensModule() { disabled={isDisabled} onClick={handleClick} > - + - + {isReady ? ( ['status'] { - switch (subStage) { - case 'error': - return 'red'; - case 'success': - return 'green'; - case 'pending': - return 'pending'; - default: - case 'idle': - return 'grey'; - } -} - -const dictionaryKey: Record = { - [CLAIM_REWARDS_STATE.SIMULATE_UPDATE_BALANCE]: 'updateBalance.simulate', - [CLAIM_REWARDS_STATE.WRITE_UPDATE_BALANCE]: 'updateBalance.write', - [CLAIM_REWARDS_STATE.TRANSACTION_UPDATE_BALANCE]: 'updateBalance.transaction', - [CLAIM_REWARDS_STATE.SIMULATE_CLAIM]: 'claimRewards.simulate', - [CLAIM_REWARDS_STATE.WRITE_CLAIM]: 'claimRewards.write', - [CLAIM_REWARDS_STATE.TRANSACTION_CLAIM]: 'claimRewards.transaction', -} as const; - -function getDictionaryKeyFromStageAndSubStage< - Stage extends CLAIM_REWARDS_STATE, - SubStage extends WriteContractStatus, ->({ - currentStage, - stage, - subStage, -}: { - currentStage: CLAIM_REWARDS_STATE; - stage: Stage; - subStage: SubStage; -}) { - return `${dictionaryKey[stage]}.${stage > currentStage || subStage === 'idle' ? 'pending' : subStage}`; -} - -function StageRow({ - currentStage, - stage, - subStage, - children, -}: { - currentStage: CLAIM_REWARDS_STATE; - stage: CLAIM_REWARDS_STATE; - subStage: WriteContractStatus; - children?: ReactNode; -}) { - const dictionary = useTranslations('modules.claim.stage'); - return ( - - currentStage - ? 'grey' - : stage < currentStage - ? 'green' - : undefined - } - /> - - {dictionary( - /** @ts-expect-error - TODO: Properly type this dictionary key construction function */ - children ?? getDictionaryKeyFromStageAndSubStage({ currentStage, stage, subStage }) - )} - - - ); -} - -function QueryStatusInformation({ - stage, - subStage, -}: { - stage: CLAIM_REWARDS_STATE; - subStage: WriteContractStatus; -}) { - return ( -
- - - - - - - - {stage === CLAIM_REWARDS_STATE.TRANSACTION_CLAIM && subStage === 'success' - ? 'done.success' - : 'done.pending'} - -
- ); -} - -const AlertTooltip = ({ tooltipContent }: { tooltipContent: ReactNode }) => { - return ( - - - - ); -}; - function ClaimTokensDialog({ formattedUnclaimedRewardsAmount, address, @@ -259,22 +135,30 @@ function ClaimTokensDialog({ excludedSigners: Array; }) { const dictionary = useTranslations('modules.claim.dialog'); + const dictionaryStage = useTranslations('modules.claim.stage'); + + const claimRewardsArgs = useMemo( + () => ({ + address, + rewards, + blsSignature, + excludedSigners, + }), + [address, rewards, blsSignature, excludedSigners] + ); const { updateBalanceAndClaimRewards, claimFee, updateBalanceFee, estimateFee, - stage, - subStage, + updateRewardsBalanceStatus, + claimRewardsStatus, enabled, skipUpdateBalance, - } = useClaimRewards({ - address, - rewards, - blsSignature, - excludedSigners, - }); + updateRewardsBalanceErrorMessage, + claimRewardsErrorMessage, + } = useClaimRewards(claimRewardsArgs); const feeEstimate = useMemo( () => @@ -296,10 +180,9 @@ function ClaimTokensDialog({ const isButtonDisabled = isDisabled || - (!skipUpdateBalance && - stage !== CLAIM_REWARDS_STATE.SIMULATE_UPDATE_BALANCE && - subStage !== 'idle') || - (skipUpdateBalance && stage !== CLAIM_REWARDS_STATE.SIMULATE_CLAIM && subStage !== 'idle'); + (skipUpdateBalance + ? claimRewardsStatus !== PROGRESS_STATUS.IDLE + : updateRewardsBalanceStatus !== PROGRESS_STATUS.IDLE); useEffect(() => { if (!isDisabled) { @@ -307,6 +190,12 @@ function ClaimTokensDialog({ } }, [address, rewards, blsSignature]); + useEffect(() => { + if (claimRewardsStatus === PROGRESS_STATUS.SUCCESS) { + toast.success(dictionary('successToast', { tokenAmount: formattedUnclaimedRewardsAmount })); + } + }, [claimRewardsStatus]); + return ( <>
@@ -337,7 +226,7 @@ function ClaimTokensDialog({ {formattedUnclaimedRewardsAmount}
- + - {enabled ? : null} + {enabled ? ( + + ) : null} ); diff --git a/apps/staking/app/mystakes/modules/StakedNodesModule.tsx b/apps/staking/app/mystakes/modules/StakedNodesModule.tsx index b67e3d4f..67dc1364 100644 --- a/apps/staking/app/mystakes/modules/StakedNodesModule.tsx +++ b/apps/staking/app/mystakes/modules/StakedNodesModule.tsx @@ -21,11 +21,7 @@ import Link from 'next/link'; import { useMemo } from 'react'; import { useStakingBackendQueryWithParams } from '@/lib/sent-staking-backend-client'; import { getStakedNodes } from '@/lib/queries/getStakedNodes'; -import { - getDateFromUnixTimestampSeconds, - getUnixTimestampNowSeconds, - timeBetweenEvents, -} from '@session/util/date'; +import { getDateFromUnixTimestampSeconds, getUnixTimestampNowSeconds } from '@session/util/date'; import { SESSION_NODE } from '@/lib/constants'; import { EXPERIMENTAL_FEATURE_FLAG, FEATURE_FLAG } from '@/lib/feature-flags'; import { useExperimentalFeatureFlag, useFeatureFlag } from '@/lib/feature-flags-client'; @@ -145,6 +141,7 @@ export const parseSessionNodeData = ( balance: node.total_contributed, operatorFee: node.operator_fee, operator_address: node.operator_address, + contract_id: node.contract_id, ...(node.awaiting_liquidation ? { awaitingLiquidation: true } : {}), ...(node.decomm_blocks_remaining ? { @@ -157,11 +154,7 @@ export const parseSessionNodeData = ( ? { unlockDate: new Date( networkTime * 1000 + - timeBetweenEvents( - node.requested_unlock_height, - currentBlock, - SESSION_NODE.BLOCK_VELOCITY - ) + (node.requested_unlock_height - currentBlock) * SESSION_NODE.MS_PER_BLOCK ), } : {}), diff --git a/apps/staking/app/register/[nodeId]/NodeRegistration.tsx b/apps/staking/app/register/[nodeId]/NodeRegistration.tsx index 17047b9f..3e7f372d 100644 --- a/apps/staking/app/register/[nodeId]/NodeRegistration.tsx +++ b/apps/staking/app/register/[nodeId]/NodeRegistration.tsx @@ -12,28 +12,22 @@ import { useStakingBackendQueryWithParams } from '@/lib/sent-staking-backend-cli import type { LoadRegistrationsResponse } from '@session/sent-staking-js/client'; import { getPendingNodes } from '@/lib/queries/getPendingNodes'; import { QUERY, SESSION_NODE } from '@/lib/constants'; -import { formatBigIntTokenValue } from '@session/util/maths'; -import { SENT_DECIMALS, SENT_SYMBOL } from '@session/contracts'; import { getDateFromUnixTimestampSeconds } from '@session/util/date'; import { notFound } from 'next/navigation'; import { generateMockRegistrations } from '@session/sent-staking-js/test'; -import useRegisterNode, { REGISTER_STAGE } from '@/hooks/useRegisterNode'; -import { StatusIndicator, statusVariants } from '@session/ui/components/StatusIndicator'; -import type { VariantProps } from 'class-variance-authority'; +import useRegisterNode from '@/hooks/useRegisterNode'; import { useQuery } from '@tanstack/react-query'; import { getNode } from '@/lib/queries/getNode'; import { type StakedNode, StakedNodeCard } from '@/components/StakedNodeCard'; -import Link from 'next/link'; -import { Tooltip } from '@session/ui/ui/tooltip'; +import { AlertTooltip, Tooltip } from '@session/ui/ui/tooltip'; import { areHexesEqual } from '@session/util/string'; -import { isProduction } from '@/lib/env'; -import type { WriteContractStatus } from '@session/contracts/hooks/useContractWriteQuery'; -import { toast } from '@session/ui/lib/sonner'; +import { toast } from '@session/ui/lib/toast'; import { RegistrationPausedInfo } from '@/components/RegistrationPausedInfo'; - import { useFeatureFlag, useRemoteFeatureFlagQuery } from '@/lib/feature-flags-client'; import { FEATURE_FLAG, REMOTE_FEATURE_FLAG } from '@/lib/feature-flags'; import { useWallet } from '@session/wallet/hooks/wallet-hooks'; +import { Progress, PROGRESS_STATUS } from '@session/ui/motion/progress'; +import { formatSENTBigInt } from '@session/contracts/hooks/SENT'; export default function NodeRegistration({ nodeId }: { nodeId: string }) { const showMockRegistration = useFeatureFlag(FEATURE_FLAG.MOCK_REGISTRATION); @@ -76,135 +70,6 @@ export default function NodeRegistration({ nodeId }: { nodeId: string }) { return isLoading ? : node ? : notFound(); } -function getStatusFromSubStage( - subStage: WriteContractStatus -): VariantProps['status'] { - switch (subStage) { - case 'error': - return 'red'; - case 'success': - return 'green'; - case 'pending': - return 'pending'; - default: - case 'idle': - return 'grey'; - } -} - -const stageDictionaryMap: Record = { - [REGISTER_STAGE.APPROVE]: 'approve', - [REGISTER_STAGE.SIMULATE]: 'simulate', - [REGISTER_STAGE.WRITE]: 'write', - [REGISTER_STAGE.TRANSACTION]: 'transaction', - [REGISTER_STAGE.JOIN]: 'join', -} as const; - -function getDictionaryKeyFromStageAndSubStage< - Stage extends REGISTER_STAGE, - SubStage extends WriteContractStatus, ->({ - currentStage, - stage, - subStage, -}: { - currentStage: REGISTER_STAGE; - stage: Stage; - subStage: SubStage; -}) { - return `${stageDictionaryMap[stage]}.${stage > currentStage || subStage === 'idle' ? 'pending' : subStage}`; -} - -function StageRow({ - currentStage, - stage, - subStage, -}: { - currentStage: REGISTER_STAGE; - stage: REGISTER_STAGE; - subStage: WriteContractStatus; -}) { - const dictionary = useTranslations('actionModules.register.stage'); - return ( - - currentStage - ? 'grey' - : stage < currentStage - ? 'green' - : undefined - } - /> - - {/** @ts-expect-error - TODO: Properly type this dictionary key construction function */} - {dictionary(getDictionaryKeyFromStageAndSubStage({ currentStage, stage, subStage }))} - - - ); -} - -function QueryStatusInformation({ - nodeId, - stage, - subStage, -}: { - nodeId: string; - stage: REGISTER_STAGE; - subStage: WriteContractStatus; -}) { - const dictionary = useTranslations('actionModules.register'); - - const { data: nodeRunning } = useQuery({ - queryKey: ['getNode', nodeId, 'checkRegistration'], - queryFn: async () => { - if (!isProduction) { - console.log('Checking if the node has joined the network'); - } - - const node = await getNode({ address: nodeId }); - - if (node && 'state' in node && node.state) { - return true; - } - throw new Error('Node has not joined the network yet'); - }, - // Allows for ~5 minutes of waiting - retry: 50, - // Retries every 30/n seconds or 5 seconds, whichever is longer - retryDelay: (attempt) => (isProduction ? Math.max((30 * 1000) / attempt, 5 * 1000) : 5000), - }); - - return ( -
- - - - - - {nodeRunning ? ( - - {dictionary.rich('goToMyStakes', { - link: () => ( - - My Stakes - - ), - })} - - ) : null} -
- ); -} - -// TODO - Add ability to set the stake amount when we build multi-contributor support function RegisterButton({ blsPubKey, blsSignature, @@ -225,21 +90,45 @@ function RegisterButton({ isRegistrationPausedFlagEnabled?: boolean; }) { const dictionary = useTranslations('actionModules.register'); - const { registerAndStake, stage, subStage, enabled } = useRegisterNode({ - blsPubKey, - blsSignature, - nodePubKey, - userSignature, - }); + const dictionaryStage = useTranslations('actionModules.register.stage'); + + const registerNodeArgs = useMemo( + () => ({ + blsPubKey, + blsSignature, + nodePubKey, + userSignature, + stakeAmount, + }), + [blsPubKey, blsSignature, nodePubKey, userSignature, stakeAmount] + ); + + const { + registerAndStake, + resetRegisterAndStake, + enabled, + allowanceReadStatus, + approveWriteStatus, + addBLSStatus, + approveErrorMessage, + addBLSErrorMessage, + } = useRegisterNode(registerNodeArgs); const handleClick = () => { if (isRegistrationPausedFlagEnabled) { toast.error(); } else { - registerAndStake(); + if (enabled) { + resetRegisterAndStake(); + registerAndStake(); + } else { + registerAndStake(); + } } }; + const tokenAmount = formatSENTBigInt(stakeAmount); + return ( <> - {enabled && (stage !== REGISTER_STAGE.APPROVE || subStage !== 'idle') ? ( - + {enabled ? ( + ) : null} ); @@ -267,12 +204,13 @@ export function NodeRegistrationForm({ const registerCardDictionary = useTranslations('nodeCard.pending'); const sessionNodeDictionary = useTranslations('sessionNodes.general'); const actionModuleSharedDictionary = useTranslations('actionModules.shared'); + const { tokenBalance } = useWallet(); const { enabled: isRegistrationPausedFlagEnabled, isLoading: isRemoteFlagLoading } = useRemoteFeatureFlagQuery(REMOTE_FEATURE_FLAG.DISABLE_NODE_REGISTRATION); const stakeAmount = BigInt(SESSION_NODE.FULL_STAKE_AMOUNT); - const stakeAmountString = formatBigIntTokenValue(stakeAmount, SENT_DECIMALS); + const stakeAmountString = formatSENTBigInt(stakeAmount); const preparationDate = getDateFromUnixTimestampSeconds(node.timestamp); const { data: runningNode, isLoading } = useQuery({ @@ -332,7 +270,17 @@ export function NodeRegistrationForm({ label={actionModuleSharedDictionary('stakeAmount')} tooltip={actionModuleSharedDictionary('stakeAmountDescription')} > - {stakeAmountString} {SENT_SYMBOL} + + {tokenBalance && tokenBalance < stakeAmount ? ( + + ) : null} + {stakeAmountString} + ) : null} getNode({ address: nodeId! }), enabled: Boolean(nodeId), @@ -30,18 +44,31 @@ export default function NotFound() { return openData?.nodes?.find((node) => areHexesEqual(node.service_node_pubkey, nodeId)); }, [openData, nodeId]); - const nodeAlreadyRunning = runningNode && 'state' in runningNode && runningNode.state; + const nodeStakedTo = useMemo(() => { + return stakedNodesData?.nodes?.find((node) => areHexesEqual(node.service_node_pubkey, nodeId)); + }, [stakedNodesData, nodeId]); + + const nodeRunningElsewhere = + runningGlobalNode && 'state' in runningGlobalNode && runningGlobalNode.state; return ( {registerDictionary('notFound.description')}
- {nodeAlreadyRunning ? ( + {nodeStakedTo ? ( + <> + + {registerDictionary.rich('notFound.foundRunningNode')} + + +
+ + ) : nodeRunningElsewhere ? ( <> - {registerDictionary('notFound.foundRunningNode')} + {registerDictionary('notFound.foundRunningNodeOtherOperator')} - +
) : null} diff --git a/apps/staking/app/register/page.tsx b/apps/staking/app/register/page.tsx index 56a83b65..3be082e7 100644 --- a/apps/staking/app/register/page.tsx +++ b/apps/staking/app/register/page.tsx @@ -1,7 +1,6 @@ import { siteMetadata } from '@/lib/metadata'; -import { ModuleGridInfoContent } from '@session/ui/components/ModuleGrid'; import { useTranslations } from 'next-intl'; -import ActionModule from '@/components/ActionModule'; +import { ActionModulePage } from '@/components/ActionModule'; export async function generateMetadata() { return siteMetadata({ @@ -13,12 +12,8 @@ export async function generateMetadata() { export default function Page() { const dictionary = useTranslations('modules.nodeRegistrations'); return ( - -
- -

{dictionary('landingP1')}

-
-
-
+ +

{dictionary('landingP1')}

+
); } diff --git a/apps/staking/app/stake/page.tsx b/apps/staking/app/stake/page.tsx index 464599f6..3be36242 100644 --- a/apps/staking/app/stake/page.tsx +++ b/apps/staking/app/stake/page.tsx @@ -1,11 +1,10 @@ import { URL } from '@/lib/constants'; import { siteMetadata } from '@/lib/metadata'; import { ButtonDataTestId } from '@/testing/data-test-ids'; -import { ModuleGridInfoContent } from '@session/ui/components/ModuleGrid'; import { Button } from '@session/ui/ui/button'; import { useTranslations } from 'next-intl'; import Link from 'next/link'; -import ActionModule from '@/components/ActionModule'; +import { ActionModulePage } from '@/components/ActionModule'; export async function generateMetadata() { return siteMetadata({ @@ -17,29 +16,25 @@ export async function generateMetadata() { export default function Page() { const dictionary = useTranslations('modules.openNodes'); return ( - -
- -

{dictionary('landingP1')}

-

{dictionary('landingP2')}

- - - -
-
-
+ +

{dictionary('landingP1')}

+

{dictionary('landingP2')}

+ + + +
); } diff --git a/apps/staking/components/ActionModule.tsx b/apps/staking/components/ActionModule.tsx index 7b0e5465..02c4b64d 100644 --- a/apps/staking/components/ActionModule.tsx +++ b/apps/staking/components/ActionModule.tsx @@ -2,6 +2,7 @@ import { ModuleGrid, ModuleGridContent, ModuleGridHeader, + ModuleGridInfoContent, ModuleGridTitle, } from '@session/ui/components/ModuleGrid'; import { QuestionIcon } from '@session/ui/icons/QuestionIcon'; @@ -17,6 +18,7 @@ type ActionModuleProps = { background?: keyof typeof actionModuleBackground; className?: string; contentClassName?: string; + noHeader?: boolean; }; export default function ActionModule({ @@ -26,25 +28,29 @@ export default function ActionModule({ children, className, contentClassName, + noHeader, }: ActionModuleProps) { return ( - - - {title ? ( - <> - {title} - {headerAction} - - ) : null} - + + {!noHeader ? ( + + {title ? ( + <> + {title} +
{headerAction}
+ + ) : null} +
+ ) : null} {children} +
); } @@ -116,3 +122,11 @@ export const ActionModuleRowSkeleton = () => ( ); export const ActionModuleDivider = () =>
; + +export const ActionModulePage = ({ children, ...props }: ActionModuleProps) => ( + +
+ {children} +
+
+); diff --git a/apps/staking/components/ChainBanner.tsx b/apps/staking/components/ChainBanner.tsx index 8d916aee..d7dc543d 100644 --- a/apps/staking/components/ChainBanner.tsx +++ b/apps/staking/components/ChainBanner.tsx @@ -8,7 +8,7 @@ import { useWallet, useWalletChain } from '@session/wallet/hooks/wallet-hooks'; import { useTranslations } from 'next-intl'; import { useMemo } from 'react'; import { SwitchChainErrorType } from 'viem'; -import { toast } from '@session/ui/lib/sonner'; +import { toast } from '@session/ui/lib/toast'; export default function ChainBanner() { const { isConnected } = useWallet(); diff --git a/apps/staking/components/ClaimRewardsDisabledInfo.tsx b/apps/staking/components/ClaimRewardsDisabledInfo.tsx new file mode 100644 index 00000000..3b9bee2f --- /dev/null +++ b/apps/staking/components/ClaimRewardsDisabledInfo.tsx @@ -0,0 +1,27 @@ +'use client'; + +import Link from 'next/link'; +import { SOCIALS } from '@/lib/constants'; +import { Social } from '@session/ui/components/SocialLinkList'; +import { ReactNode } from 'react'; +import { useTranslations } from 'next-intl'; + +export function ClaimRewardsDisabledInfo() { + const dictionary = useTranslations('banner'); + return ( + + {dictionary.rich('claimRewardsDisabled', { + link: (children: ReactNode) => ( + + {children} + + ), + })} + + ); +} diff --git a/apps/staking/components/DevSheet.tsx b/apps/staking/components/DevSheet.tsx index 098d614d..4af8cf44 100644 --- a/apps/staking/components/DevSheet.tsx +++ b/apps/staking/components/DevSheet.tsx @@ -10,7 +10,7 @@ import { import { Switch } from '@session/ui/ui/switch'; import { Tooltip } from '@session/ui/ui/tooltip'; import { usePathname } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { SOCIALS } from '@/lib/constants'; import { Social } from '@session/ui/components/SocialLinkList'; @@ -29,6 +29,16 @@ import { useRemoteFeatureFlagsQuery, useSetFeatureFlag, } from '@/lib/feature-flags-client'; +import { CopyToClipboardButton } from '@session/ui/components/CopyToClipboardButton'; +import { + formatSENTBigInt, + useAllowanceQuery, + useProxyApproval, +} from '@session/contracts/hooks/SENT'; +import { addresses, SENT_DECIMALS } from '@session/contracts'; +import { LoadingText } from '@session/ui/components/loading-text'; +import { Button } from '@session/ui/ui/button'; +import { Input } from '@session/ui/ui/input'; export function DevSheet({ buildInfo }: { buildInfo: BuildInfo }) { const [isOpen, setIsOpen] = useState(false); @@ -55,6 +65,25 @@ export function DevSheet({ buildInfo }: { buildInfo: BuildInfo }) { }; }, []); + const { COMMIT_HASH, COMMIT_HASH_PRETTY } = buildInfo.env; + + const remoteFeatureFlagArray = useMemo(() => (data ? Array.from(data) : []), [data]); + + const textToCopy = useMemo(() => { + const enabledFeatureFlags = Object.entries(featureFlags) + .filter(([, enabled]) => enabled) + .map(([flag]) => flag); + const sections = [ + `Commit Hash: ${COMMIT_HASH}`, + `Build Env: ${getEnvironment()}`, + `Is Production: ${isProduction ? 'True' : 'False'}`, + `Remote Feature Flags: ${data ? (remoteFeatureFlagArray.length > 0 ? remoteFeatureFlagArray.join(', ') : 'None') : 'Loading...'}`, + `Feature Flags: ${enabledFeatureFlags.length > 0 ? enabledFeatureFlags.join(', ') : 'None'}`, + `User Agent: ${navigator.userAgent}`, + ]; + return sections.join('\n'); + }, [data, featureFlags, remoteFeatureFlagArray, navigator.userAgent]); + return ( setIsOpen(false)}> @@ -63,7 +92,16 @@ export function DevSheet({ buildInfo }: { buildInfo: BuildInfo }) { This sheet only shows when the site is in development mode. - Build Info + + Build Info{' '} + {data ? ( + + ) : null} + {'Commit Hash:'} - {buildInfo.env.COMMIT_HASH_PRETTY} + {COMMIT_HASH_PRETTY} @@ -85,7 +123,7 @@ export function DevSheet({ buildInfo }: { buildInfo: BuildInfo }) { Remote Feature Flags {data - ? Array.from(data).map((flag) => ( + ? remoteFeatureFlagArray.map((flag) => (
{'• '} {remoteFeatureFlagsInfo[flag].name} @@ -103,6 +141,8 @@ export function DevSheet({ buildInfo }: { buildInfo: BuildInfo }) { ))} + Contract Actions + @@ -159,3 +199,63 @@ function FeatureFlagToggle({ ); } + +function ContractActions() { + const [value, setValue] = useState('0'); + const serviceNodeRewardsAddress = addresses.ServiceNodeRewards.testnet; + + const tokenAmount = useMemo(() => BigInt(value) * BigInt(10 ** SENT_DECIMALS), [value]); + + const { + allowance, + refetch, + status: allowanceStatus, + } = useAllowanceQuery({ + contractAddress: serviceNodeRewardsAddress, + }); + + const { approveWrite, resetApprove, status } = useProxyApproval({ + contractAddress: serviceNodeRewardsAddress, + tokenAmount, + }); + + const handleClick = () => { + if (status !== 'idle') { + resetApprove(); + } + approveWrite(); + }; + + useEffect(() => { + if (status === 'success') refetch(); + }, [status]); + + return ( + <> + + + {'Allowance:'} + + {allowanceStatus === 'success' ? formatSENTBigInt(allowance) : } + + + + setValue(e.target.value)} /> + + + ); +} diff --git a/apps/staking/components/DropdownHamburgerMenu.tsx b/apps/staking/components/DropdownHamburgerMenu.tsx index 8e33d17f..a4fadf2f 100644 --- a/apps/staking/components/DropdownHamburgerMenu.tsx +++ b/apps/staking/components/DropdownHamburgerMenu.tsx @@ -29,7 +29,7 @@ export function DropdownHamburgerMenu() {
-
+
- +
diff --git a/apps/staking/components/NodeCard.tsx b/apps/staking/components/NodeCard.tsx index 02bb997e..a7d74435 100644 --- a/apps/staking/components/NodeCard.tsx +++ b/apps/staking/components/NodeCard.tsx @@ -6,9 +6,10 @@ import { useWallet } from '@session/wallet/hooks/wallet-hooks'; import { cva, type VariantProps } from 'class-variance-authority'; import { forwardRef, type HTMLAttributes, useMemo } from 'react'; import { bigIntToNumber } from '@session/util/maths'; -import { formatSENT, SENT_DECIMALS, SENT_SYMBOL } from '@session/contracts'; +import { SENT_DECIMALS } from '@session/contracts'; import { useTranslations } from 'next-intl'; import { areHexesEqual } from '@session/util/string'; +import { formatSENTBigInt } from '@session/contracts/hooks/SENT'; export interface Contributor { address: string; @@ -128,7 +129,7 @@ const ContributorIcon = ({ tooltipContent={

{contributor - ? `${isUser ? dictionary('you') : contributor.address} | ${formatSENT(contributor.amount)} ${SENT_SYMBOL}` + ? `${isUser ? dictionary('you') : contributor.address} | ${formatSENTBigInt(contributor.amount)}` : dictionary('emptySlot')}

} @@ -141,7 +142,14 @@ const ContributorIcon = ({ ); }; -export const getTotalStakedAmountForAddress = ( +/** + * @deprecated Use {@link getTotalStakedAmountForAddress} instead. + * Returns the total staked amount for a given address. + * @param contributors - The list of contributors. + * @param address - The address to check. + * @returns The total staked amount for the given address. + */ +export const getTotalStakedAmountForAddressNumber = ( contributors: Contributor[], address: string ): number => { @@ -152,6 +160,34 @@ export const getTotalStakedAmountForAddress = ( }, 0); }; +export const getTotalStakedAmountForAddressBigInt = ( + contributors: Contributor[], + address: string +): bigint => { + contributors = contributors.map(({ amount, address: contributorAddress }) => { + return { + amount: typeof amount === 'bigint' ? amount : BigInt(`${amount}`), + address: contributorAddress, + }; + }); + return contributors.reduce((acc, { amount, address: contributorAddress }) => { + return areHexesEqual(contributorAddress, address) ? acc + amount : acc; + }, BigInt(0)); +}; + +export const getTotalStakedAmountForAddress = ( + contributors: Contributor[], + address: string, + decimals?: number, + hideSymbol?: boolean +): string => { + return formatSENTBigInt( + getTotalStakedAmountForAddressBigInt(contributors, address), + decimals, + hideSymbol + ); +}; + type StakedNodeContributorListProps = HTMLAttributes & { contributors: Contributor[]; showEmptySlots?: boolean; diff --git a/apps/staking/components/RemoteBanner.tsx b/apps/staking/components/RemoteBanner.tsx index 8cbfa0f0..3cd83874 100644 --- a/apps/staking/components/RemoteBanner.tsx +++ b/apps/staking/components/RemoteBanner.tsx @@ -3,6 +3,7 @@ import { REMOTE_FEATURE_FLAG } from '@/lib/feature-flags'; import { Banner } from '@session/ui/components/Banner'; import { RegistrationPausedInfo } from '@/components/RegistrationPausedInfo'; import { NewTokenContractInfo } from '@/components/NewTokenContractInfo'; +import { ClaimRewardsDisabledInfo } from '@/components/ClaimRewardsDisabledInfo'; export default async function RemoteBanner() { /** @@ -28,6 +29,11 @@ export default async function RemoteBanner() { ) : null} + {enabledFlags.has(REMOTE_FEATURE_FLAG.DISABLE_CLAIM_REWARDS) ? ( + + + + ) : null} {enabledFlags.has(REMOTE_FEATURE_FLAG.DISABLE_NODE_REGISTRATION) ? ( diff --git a/apps/staking/components/StakedNode/NodeActionModuleInfo.tsx b/apps/staking/components/StakedNode/NodeActionModuleInfo.tsx new file mode 100644 index 00000000..4886ea79 --- /dev/null +++ b/apps/staking/components/StakedNode/NodeActionModuleInfo.tsx @@ -0,0 +1,81 @@ +import { ActionModuleRow } from '@/components/ActionModule'; +import { getTotalStakedAmountForAddress, NodeContributorList } from '@/components/NodeCard'; +import { PubKey } from '@session/ui/components/PubKey'; +import { externalLink } from '@/lib/locale-defaults'; +import { TICKER, URL } from '@/lib/constants'; +import { LoadingText } from '@session/ui/components/loading-text'; +import { SENT_SYMBOL } from '@session/contracts'; +import { useTranslations } from 'next-intl'; +import { useMemo } from 'react'; +import { StakedNode } from '@/components/StakedNodeCard'; +import { useWallet } from '@session/wallet/hooks/wallet-hooks'; + +export default function NodeActionModuleInfo({ + node, + feeEstimate, + feeEstimateText, +}: { + node: StakedNode; + feeEstimate?: string | null; + feeEstimateText?: string; +}) { + const { address } = useWallet(); + const dictionary = useTranslations('nodeCard.staked.requestExit.dialog.write'); + const dictionaryActionModulesNode = useTranslations('actionModules.node'); + const sessionNodeDictionary = useTranslations('sessionNodes.general'); + + const stakedAmount = useMemo( + () => + address ? getTotalStakedAmountForAddress(node.contributors, address) : `0 ${SENT_SYMBOL}`, + [node.contributors, address] + ); + + return ( +
+ + + + + + + + + + {node.contributors[0]?.address ? ( + + ) : null} + + {typeof feeEstimate !== 'undefined' ? ( + + + {feeEstimate ? ( + `${feeEstimate} ${TICKER.ETH}` + ) : ( + + )} + + + ) : null} + + {stakedAmount} + +
+ ); +} diff --git a/apps/staking/components/StakedNode/NodeExitButton.tsx b/apps/staking/components/StakedNode/NodeExitButton.tsx new file mode 100644 index 00000000..e5b74bcc --- /dev/null +++ b/apps/staking/components/StakedNode/NodeExitButton.tsx @@ -0,0 +1,36 @@ +import { ButtonDataTestId } from '@/testing/data-test-ids'; +import { useTranslations } from 'next-intl'; +import { Button } from '@session/ui/ui/button'; +import { CollapsableContent } from '@/components/StakedNodeCard'; +import { forwardRef, type HTMLAttributes } from 'react'; +import { cn } from '@session/ui/lib/utils'; + +export const NodeExitButton = forwardRef< + HTMLSpanElement, + HTMLAttributes & { + disabled?: boolean; + } +>(({ disabled, className, ...props }, ref) => { + const dictionary = useTranslations('nodeCard.staked.exit'); + return ( + + + + ); +}); diff --git a/apps/staking/components/StakedNode/NodeExitButtonDialog.tsx b/apps/staking/components/StakedNode/NodeExitButtonDialog.tsx new file mode 100644 index 00000000..76fa0aae --- /dev/null +++ b/apps/staking/components/StakedNode/NodeExitButtonDialog.tsx @@ -0,0 +1,201 @@ +import { type StakedNode } from '@/components/StakedNodeCard'; +import { useTranslations } from 'next-intl'; +import { useRemoteFeatureFlagQuery } from '@/lib/feature-flags-client'; +import { REMOTE_FEATURE_FLAG } from '@/lib/feature-flags'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogFooter, + AlertDialogTrigger, +} from '@session/ui/ui/alert-dialog'; +import { ButtonDataTestId } from '@/testing/data-test-ids'; +import { Loading } from '@session/ui/components/loading'; +import { type ReactNode, useEffect, useMemo } from 'react'; +import Link from 'next/link'; +import { SOCIALS } from '@/lib/constants'; +import { Social } from '@session/ui/components/SocialLinkList'; +import { useWallet } from '@session/wallet/hooks/wallet-hooks'; +import { formatBigIntTokenValue } from '@session/util/maths'; +import { ETH_DECIMALS } from '@session/wallet/lib/eth'; +import { getTotalStakedAmountForAddress } from '@/components/NodeCard'; +import { Button } from '@session/ui/ui/button'; +import { NodeExitButton } from '@/components/StakedNode/NodeExitButton'; +import { useStakingBackendQueryWithParams } from '@/lib/sent-staking-backend-client'; +import { getNodeExitSignatures } from '@/lib/queries/getNodeExitSignatures'; +import NodeActionModuleInfo from '@/components/StakedNode/NodeActionModuleInfo'; +import { SENT_SYMBOL } from '@session/contracts'; +import { Progress, PROGRESS_STATUS } from '@session/ui/motion/progress'; +import useExitNode from '@/hooks/useExitNode'; + +export function NodeExitButtonDialog({ node }: { node: StakedNode }) { + const dictionary = useTranslations('nodeCard.staked.exit'); + const { enabled: isNodeExitDisabled, isLoading: isRemoteFlagLoading } = useRemoteFeatureFlagQuery( + REMOTE_FEATURE_FLAG.DISABLE_NODE_EXIT + ); + + const { data, status } = useStakingBackendQueryWithParams(getNodeExitSignatures, { + nodePubKey: node.pubKey, + }); + + return ( + + + + + + {isRemoteFlagLoading || !data || status !== 'success' ? ( + + ) : isNodeExitDisabled ? ( + + ) : ( + + )} + + + ); +} + +function NodeExitDisabled() { + const dictionary = useTranslations('nodeCard.staked.exit'); + return ( +

+ {dictionary.rich('disabledInfo', { + link: (children: ReactNode) => ( + + {children} + + ), + })} +

+ ); +} + +function NodeExitContractWriteDialog({ + node, + blsPubKey, + timestamp, + blsSignature, + excludedSigners, +}: { + node: StakedNode; + blsPubKey: string; + timestamp: number; + blsSignature: string; + excludedSigners?: Array; +}) { + const dictionary = useTranslations('nodeCard.staked.exit.dialog'); + const stageDictKey = 'nodeCard.staked.exit.stage' as const; + const dictionaryStage = useTranslations(stageDictKey); + const { address } = useWallet(); + + const removeBlsPublicKeyWithSignatureArgs = useMemo( + () => ({ + blsPubKey, + timestamp, + blsSignature, + excludedSigners: excludedSigners?.map(BigInt), + }), + [blsPubKey, timestamp, blsSignature, excludedSigners] + ); + + const { + removeBLSPublicKeyWithSignature, + fee, + estimateContractWriteFee, + simulateEnabled, + resetContract, + status, + errorMessage, + } = useExitNode(removeBlsPublicKeyWithSignatureArgs); + + const feeEstimate = useMemo( + () => (fee !== null ? formatBigIntTokenValue(fee ?? BigInt(0), ETH_DECIMALS, 18) : null), + [fee] + ); + + const stakedAmount = useMemo( + () => + address ? getTotalStakedAmountForAddress(node.contributors, address) : `0 ${SENT_SYMBOL}`, + [node.contributors, address] + ); + + const handleClick = () => { + if (simulateEnabled) { + resetContract(); + } + removeBLSPublicKeyWithSignature(); + }; + + const isDisabled = !blsPubKey || !timestamp || !blsSignature; + + useEffect(() => { + if (!isDisabled) { + estimateContractWriteFee(); + } + }, [node.contract_id]); + + return ( + <> + + + + {simulateEnabled ? ( + + ) : null} + + + ); +} diff --git a/apps/staking/components/StakedNode/NodeRequestExitButton.tsx b/apps/staking/components/StakedNode/NodeRequestExitButton.tsx new file mode 100644 index 00000000..a1846f9c --- /dev/null +++ b/apps/staking/components/StakedNode/NodeRequestExitButton.tsx @@ -0,0 +1,266 @@ +import { ButtonDataTestId } from '@/testing/data-test-ids'; +import { useTranslations } from 'next-intl'; +import { CollapsableButton, type StakedNode } from '@/components/StakedNodeCard'; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogTrigger, +} from '@session/ui/ui/alert-dialog'; +import { Button } from '@session/ui/ui/button'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; +import { formatLocalizedTimeFromSeconds } from '@/lib/locale-client'; +import { SESSION_NODE_TIME, SOCIALS, URL } from '@/lib/constants'; +import { externalLink } from '@/lib/locale-defaults'; +import { useChain } from '@session/contracts/hooks/useChain'; +import { useWallet } from '@session/wallet/hooks/wallet-hooks'; +import { getTotalStakedAmountForAddress } from '@/components/NodeCard'; +import { formatBigIntTokenValue } from '@session/util/maths'; +import { ETH_DECIMALS } from '@session/wallet/lib/eth'; +import { useRemoteFeatureFlagQuery } from '@/lib/feature-flags-client'; +import { REMOTE_FEATURE_FLAG } from '@/lib/feature-flags'; +import { Loading } from '@session/ui/components/loading'; +import Link from 'next/link'; +import { Social } from '@session/ui/components/SocialLinkList'; +import { ChevronsDownIcon } from '@session/ui/icons/ChevronsDownIcon'; +import { Progress, PROGRESS_STATUS } from '@session/ui/motion/progress'; +import useRequestNodeExit from '@/hooks/useRequestNodeExit'; +import NodeActionModuleInfo from '@/components/StakedNode/NodeActionModuleInfo'; +import { SENT_SYMBOL } from '@session/contracts'; + +enum EXIT_REQUEST_STATE { + ALERT, + PENDING, +} + +export function NodeRequestExitButton({ node }: { node: StakedNode }) { + const [exitRequestState, setExitRequestState] = useState( + EXIT_REQUEST_STATE.ALERT + ); + const dictionary = useTranslations('nodeCard.staked.requestExit'); + const { enabled: isNodeExitRequestDisabled, isLoading: isRemoteFlagLoading } = + useRemoteFeatureFlagQuery(REMOTE_FEATURE_FLAG.DISABLE_REQUEST_NODE_EXIT); + + return ( + + + + {dictionary('buttonText')} + + + + {exitRequestState !== EXIT_REQUEST_STATE.ALERT ? ( + setExitRequestState(EXIT_REQUEST_STATE.ALERT)} + /> + ) : null} + {dictionary('dialog.title')} + + } + className="text-center" + > + {isRemoteFlagLoading ? ( + + ) : isNodeExitRequestDisabled ? ( + + ) : exitRequestState === EXIT_REQUEST_STATE.PENDING ? ( + + ) : ( + setExitRequestState(EXIT_REQUEST_STATE.PENDING)} + /> + )} + + + ); +} + +function RequestNodeExitDisabled() { + const dictionary = useTranslations('nodeCard.staked.requestExit'); + return ( +

+ {dictionary.rich('disabledInfo', { + link: (children: ReactNode) => ( + + {children} + + ), + })} +

+ ); +} + +function RequestNodeExitDialog({ node, onSubmit }: { node: StakedNode; onSubmit: () => void }) { + const chain = useChain(); + const dictionary = useTranslations('nodeCard.staked.requestExit.dialog'); + + return ( + <> +
{dictionary('description1')}
+

+ {dictionary('description2', { + request_time: formatLocalizedTimeFromSeconds( + SESSION_NODE_TIME(chain).EXIT_REQUEST_TIME_SECONDS, + { + addSuffix: true, + } + ), + })} +
+
+ {dictionary.rich('description3', { + request_time: formatLocalizedTimeFromSeconds( + SESSION_NODE_TIME(chain).EXIT_REQUEST_TIME_SECONDS + ), + exit_time: formatLocalizedTimeFromSeconds( + SESSION_NODE_TIME(chain).EXIT_GRACE_TIME_SECONDS + ), + link: externalLink(URL.NODE_LIQUIDATION_LEARN_MORE), + })} +
+
+ {dictionary('description4')} +

+ + + + + + + + ); +} + +function RequestNodeExitContractWriteDialog({ node }: { node: StakedNode }) { + const stageDictKey = 'nodeCard.staked.requestExit.dialog.stage' as const; + const dictionary = useTranslations('nodeCard.staked.requestExit.dialog.write'); + const dictionaryStage = useTranslations(stageDictKey); + const { address } = useWallet(); + + const { + initiateRemoveBLSPublicKey, + fee, + estimateContractWriteFee, + simulateEnabled, + resetContract, + status, + errorMessage, + } = useRequestNodeExit({ + contractId: node.contract_id, + }); + + const feeEstimate = useMemo( + () => (fee !== null ? formatBigIntTokenValue(fee ?? BigInt(0), ETH_DECIMALS, 18) : null), + [fee] + ); + + const stakedAmount = useMemo( + () => + address ? getTotalStakedAmountForAddress(node.contributors, address) : `0 ${SENT_SYMBOL}`, + [node.contributors, address] + ); + + const handleClick = () => { + if (simulateEnabled) { + resetContract(); + } + initiateRemoveBLSPublicKey(); + }; + + const isDisabled = !node.contract_id; + + useEffect(() => { + if (!isDisabled) { + estimateContractWriteFee(); + } + }, [node.contract_id]); + + return ( + <> + + + + {simulateEnabled ? ( + + ) : null} + + + ); +} diff --git a/apps/staking/components/StakedNodeCard.tsx b/apps/staking/components/StakedNodeCard.tsx index 2065285c..a24f2c1c 100644 --- a/apps/staking/components/StakedNodeCard.tsx +++ b/apps/staking/components/StakedNodeCard.tsx @@ -1,15 +1,22 @@ 'use client'; -import { formatLocalizedRelativeTimeToNowClient, formatPercentage } from '@/lib/locale-client'; -import { NodeCardDataTestId, StakedNodeDataTestId } from '@/testing/data-test-ids'; +import { + formatDate, + formatLocalizedRelativeTimeToNowClient, + formatLocalizedTimeFromSeconds, + formatPercentage, +} from '@/lib/locale-client'; +import { + ButtonDataTestId, + NodeCardDataTestId, + StakedNodeDataTestId, +} from '@/testing/data-test-ids'; import { SENT_SYMBOL } from '@session/contracts'; import { NODE_STATE } from '@session/sent-staking-js/client'; -import { TextSeparator } from '@session/ui/components/Separator'; import { StatusIndicator, statusVariants } from '@session/ui/components/StatusIndicator'; import { ArrowDownIcon } from '@session/ui/icons/ArrowDownIcon'; import { SpannerAndScrewdriverIcon } from '@session/ui/icons/SpannerAndScrewdriverIcon'; import { cn } from '@session/ui/lib/utils'; -import { formatNumber } from '@session/util/maths'; import { useWallet } from '@session/wallet/hooks/wallet-hooks'; import { cva, type VariantProps } from 'class-variance-authority'; import { useTranslations } from 'next-intl'; @@ -24,6 +31,15 @@ import { } from './NodeCard'; import { PubKey } from '@session/ui/components/PubKey'; import { areHexesEqual } from '@session/util/string'; +import { Button } from '@session/ui/ui/button'; +import { NodeRequestExitButton } from '@/components/StakedNode/NodeRequestExitButton'; +import { Tooltip } from '@session/ui/ui/tooltip'; +import { SESSION_NODE, SESSION_NODE_TIME, URL } from '@/lib/constants'; +import { useChain } from '@session/contracts/hooks/useChain'; +import { NodeExitButton } from '@/components/StakedNode/NodeExitButton'; +import { NodeExitButtonDialog } from '@/components/StakedNode/NodeExitButtonDialog'; +import { externalLink } from '@/lib/locale-defaults'; +import { TextSeparator } from '@session/ui/components/Separator'; export const NODE_STATE_VALUES = Object.values(NODE_STATE); @@ -38,6 +54,7 @@ export interface GenericStakedNode { balance: number; operatorFee: number; operator_address: string; + contract_id: number; } type RunningStakedNode = GenericStakedNode & { @@ -53,7 +70,7 @@ type CancelledStakedNode = GenericStakedNode & { state: NODE_STATE.CANCELLED }; type DecommissionedStakedNode = GenericStakedNode & { state: NODE_STATE.DECOMMISSIONED; - deregistrationDate?: Date; + deregistrationDate: Date; unlockDate?: Date; }; @@ -77,23 +94,23 @@ export type StakedNode = // #endregion // #region - Assertions /** Type assertions */ -const isRunning = (node: StakedNode): node is RunningStakedNode => - node.state === NODE_STATE.RUNNING; - -const isAwaitingContributors = (node: StakedNode): node is AwaitingContributorsStakedNode => - node.state === NODE_STATE.AWAITING_CONTRIBUTORS; - -const isCancelled = (node: StakedNode): node is CancelledStakedNode => - node.state === NODE_STATE.CANCELLED; - -const isDecommissioned = (node: StakedNode): node is DecommissionedStakedNode => - node.state === NODE_STATE.DECOMMISSIONED; - -const isDeregistered = (node: StakedNode): node is DeregisteredStakedNode => - node.state === NODE_STATE.DEREGISTERED; - -const isUnlocked = (node: StakedNode): node is UnlockedStakedNode => - node.state === NODE_STATE.UNLOCKED; +// const isRunning = (node: StakedNode): node is RunningStakedNode => +// node.state === NODE_STATE.RUNNING; +// +// const isAwaitingContributors = (node: StakedNode): node is AwaitingContributorsStakedNode => +// node.state === NODE_STATE.AWAITING_CONTRIBUTORS; +// +// const isCancelled = (node: StakedNode): node is CancelledStakedNode => +// node.state === NODE_STATE.CANCELLED; +// +// const isDecommissioned = (node: StakedNode): node is DecommissionedStakedNode => +// node.state === NODE_STATE.DECOMMISSIONED; +// +// const isDeregistered = (node: StakedNode): node is DeregisteredStakedNode => +// node.state === NODE_STATE.DEREGISTERED; +// +// const isUnlocked = (node: StakedNode): node is UnlockedStakedNode => +// node.state === NODE_STATE.UNLOCKED; /** State assertions */ @@ -103,12 +120,26 @@ const isUnlocked = (node: StakedNode): node is UnlockedStakedNode => * @returns `true` if the node is being deregistered, `false` otherwise. */ const isBeingDeregistered = (node: StakedNode): node is DecommissionedStakedNode => - node.state === NODE_STATE.DECOMMISSIONED; + !!( + node.state === NODE_STATE.DECOMMISSIONED && + 'deregistrationDate' in node && + node.deregistrationDate + ); -const isBeingUnlocked = ( +const hasUnlockDate = ( node: StakedNode ): node is (RunningStakedNode | DecommissionedStakedNode) & { unlockDate: Date } => - 'unlockDate' in node && node.unlockDate !== undefined; + !!('unlockDate' in node && node.unlockDate); + +const isRequestingToExit = ( + node: StakedNode +): node is (RunningStakedNode | DecommissionedStakedNode) & { unlockDate: Date } => + hasUnlockDate(node) && node.unlockDate.getTime() >= Date.now(); + +const isReadyToExit = ( + node: StakedNode +): node is (RunningStakedNode | DecommissionedStakedNode) & { unlockDate: Date } => + hasUnlockDate(node) && node.unlockDate.getTime() < Date.now(); /** * Checks if a given node is awaiting liquidation. @@ -134,8 +165,8 @@ const isNodeOperator = (node: StakedNode, operatorAddress: string): boolean => * @param contributorAddress - The address of the contributor to check. * @returns `true` if the contributor address is a contributor of the session node, `false` otherwise. */ -const isNodeContributor = (node: StakedNode, contributorAddress: string): boolean => - node.contributors.some(({ address }) => areHexesEqual(address, contributorAddress)); +// const isNodeContributor = (node: StakedNode, contributorAddress: string): boolean => +// node.contributors.some(({ address }) => areHexesEqual(address, contributorAddress)); function getNodeStatus(state: NODE_STATE): VariantProps['status'] { switch (state) { @@ -224,35 +255,100 @@ const NodeOperatorIndicator = forwardRef -
- - {dictionary('operator')} -
- + +
+ + {dictionary('operator')} +
+
); } ); -const NodeSummary = ({ node }: { node: StakedNode }) => { +const ReadyForExitNotification = ({ + node, + className, +}: { + node: Required | Required; + className?: string; +}) => { + const chain = useChain(); const dictionary = useTranslations('nodeCard.staked'); - const generalDictionary = useTranslations('general'); + const time = formatLocalizedTimeFromSeconds( + node.unlockDate.getTime() / 1000 + SESSION_NODE_TIME(chain).EXIT_GRACE_TIME_SECONDS, + { addSuffix: true } + ); - if (isAwaitingLiquidation(node)) { - return ( - {dictionary('liquidationNotification')} - ); - } + return ( + + + {dictionary.rich('exitTimerNotification', { + time, + link: externalLink(URL.NODE_LIQUIDATION_LEARN_MORE), + })} + + + ); +}; - if (isBeingDeregistered(node)) { - return ( +const RequestingExitNotification = ({ + node, + className, +}: { + node: Required | Required; + className?: string; +}) => { + const dictionary = useTranslations('nodeCard.staked'); + return ( + + + {dictionary('requestingExitTimerNotification', { + time: formatLocalizedRelativeTimeToNowClient(node.unlockDate, { addSuffix: true }), + })} + + + ); +}; + +const DeregisteringNotification = ({ node }: { node: DecommissionedStakedNode }) => { + const chain = useChain(); + const dictionary = useTranslations('nodeCard.staked'); + const generalDictionary = useTranslations('general'); + return ( + {dictionary('deregistrationTimerNotification', { time: node.deregistrationDate @@ -262,21 +358,43 @@ const NodeSummary = ({ node }: { node: StakedNode }) => { : generalDictionary('soon'), })} + + ); +}; + +const NodeSummary = ({ node }: { node: StakedNode }) => { + const dictionary = useTranslations('nodeCard.staked'); + + if (isAwaitingLiquidation(node)) { + return ( + {dictionary('liquidationNotification')} + ); + } + + if (isBeingDeregistered(node)) { + return ; + } + + if (isRequestingToExit(node)) { + return ( + <> + + + ); } - if (isBeingUnlocked(node)) { + if (isReadyToExit(node)) { return ( <> - - {dictionary('unlockingTimerNotification', { - time: formatLocalizedRelativeTimeToNowClient(node.unlockDate, { addSuffix: true }), - })} - + ); } @@ -295,28 +413,34 @@ const NodeSummary = ({ node }: { node: StakedNode }) => { }; const collapsableContentVariants = cva( - 'h-full max-h-0 w-full select-none gap-1 overflow-y-hidden transition-all duration-300 ease-in-out peer-checked:select-auto motion-reduce:transition-none', + 'h-full max-h-0 select-none gap-1 overflow-y-hidden transition-all duration-300 ease-in-out peer-checked:select-auto motion-reduce:transition-none', { variants: { size: { xs: 'text-xs md:text-xs peer-checked:max-h-4', base: cn('text-sm peer-checked:max-h-5', 'md:text-base md:peer-checked:max-h-6'), + buttonMd: cn('peer-checked:max-h-10'), + }, + width: { + 'w-full': 'w-full', + 'w-max': 'w-max', }, }, defaultVariants: { size: 'base', + width: 'w-full', }, } ); -type CollapsableContentProps = React.HTMLAttributes & +type CollapsableContentProps = HTMLAttributes & VariantProps; -const CollapsableContent = forwardRef( - ({ className, size, ...props }, ref) => ( +export const CollapsableContent = forwardRef( + ({ className, size, width, ...props }, ref) => ( ) @@ -326,10 +450,39 @@ const RowLabel = ({ children }: { children: ReactNode }) => ( {children} ); +export const CollapsableButton = forwardRef< + HTMLButtonElement, + HTMLAttributes & { + ariaLabel: string; + dataTestId: ButtonDataTestId; + disabled?: boolean; + mobileChildren?: ReactNode; + } +>(({ ariaLabel, dataTestId, disabled, children, ...props }, ref) => ( + + + +)); + const StakedNodeCard = forwardRef< HTMLDivElement, - HTMLAttributes & { node: StakedNode } ->(({ className, node, ...props }, ref) => { + HTMLAttributes & { node: StakedNode; hideButton?: boolean } +>(({ className, node, hideButton, ...props }, ref) => { const dictionary = useTranslations('nodeCard.staked'); const generalDictionary = useTranslations('general'); const generalNodeDictionary = useTranslations('sessionNodes.general'); @@ -338,11 +491,19 @@ const StakedNodeCard = forwardRef< const id = useId(); const { address } = useWallet(); - const { state, pubKey, operatorFee, lastRewardHeight, lastUptime, contributors } = node; + const { + state, + pubKey, + operatorFee, + lastRewardHeight, + lastUptime, + contributors, + operator_address, + } = node; const formattedTotalStakedAmount = useMemo(() => { - if (!contributors || contributors.length === 0 || !address) return '0'; - return formatNumber(getTotalStakedAmountForAddress(contributors, address)); + if (!contributors || contributors.length === 0 || !address) return `0 ${SENT_SYMBOL}`; + return getTotalStakedAmountForAddress(contributors, address); }, [contributors, address]); const isSoloNode = contributors.length === 1; @@ -352,7 +513,7 @@ const StakedNodeCard = forwardRef< ref={ref} {...props} className={cn( - 'flex flex-row flex-wrap items-center gap-x-2 gap-y-0.5 overflow-hidden pb-4 align-middle', + 'relative flex flex-row flex-wrap items-center gap-x-2 gap-y-0.5 overflow-hidden pb-4 align-middle', className )} data-testid={NodeCardDataTestId.Staked_Node} @@ -366,50 +527,75 @@ const StakedNodeCard = forwardRef< {state} - {isBeingDeregistered(node) && isBeingUnlocked(node) ? ( + {isBeingDeregistered(node) && isRequestingToExit(node) ? ( - {dictionary('unlockingTimerNotification', { - time: formatLocalizedRelativeTimeToNowClient(node.unlockDate, { addSuffix: true }), - })} + ) : null} {state === NODE_STATE.DECOMMISSIONED || state === NODE_STATE.DEREGISTERED || state === NODE_STATE.UNLOCKED ? ( - - {dictionary('lastRewardHeight', { - height: lastRewardHeight ? lastRewardHeight : generalDictionary('notFound'), - })} + + + + {dictionary('lastRewardHeight', { + height: lastRewardHeight ? lastRewardHeight : generalDictionary('notFound'), + })} + + ) : null} - - {dictionary('lastUptime', { - time: lastUptime.getTime() - ? formatLocalizedRelativeTimeToNowClient(lastUptime, { addSuffix: true }) - : generalDictionary('notFound'), - })} + + + + {dictionary('lastUptime', { + time: lastUptime.getTime() + ? formatLocalizedRelativeTimeToNowClient(lastUptime, { addSuffix: true }) + : generalDictionary('notFound'), + })} + + {/** NOTE - ensure any changes here still work with the pubkey component */} - + {address && isNodeOperator(node, address) ? : null} + {titleFormat('format', { title: generalNodeDictionary('publicKeyShort') })} - + - + {titleFormat('format', { title: generalNodeDictionary('operatorAddress') })} - + {titleFormat('format', { title: stakingNodeDictionary('stakedBalance') })} - {formattedTotalStakedAmount} {SENT_SYMBOL} + {formattedTotalStakedAmount} {!isSoloNode ? ( @@ -419,6 +605,24 @@ const StakedNodeCard = forwardRef< {formatPercentage(operatorFee)} ) : null} + {state === NODE_STATE.RUNNING && !hideButton ? ( + isReadyToExit(node) ? ( + + ) : isRequestingToExit(node) ? ( + + + + ) : ( + + ) + ) : null} ); }); diff --git a/apps/staking/components/TOSHandler.tsx b/apps/staking/components/TOSHandler.tsx index 186d46e3..c19095d0 100644 --- a/apps/staking/components/TOSHandler.tsx +++ b/apps/staking/components/TOSHandler.tsx @@ -24,7 +24,7 @@ import { FormLabel, FormSubmitButton, } from '@session/ui/components/ui/form'; -import { toast } from '@session/ui/lib/sonner'; +import { toast } from '@session/ui/lib/toast'; import { XIcon } from '@session/ui/icons/XIcon'; import { ButtonDataTestId } from '@/testing/data-test-ids'; import { FEATURE_FLAG } from '@/lib/feature-flags'; diff --git a/apps/staking/components/Toast.tsx b/apps/staking/components/Toast.tsx index 00598ec6..701e9d58 100644 --- a/apps/staking/components/Toast.tsx +++ b/apps/staking/components/Toast.tsx @@ -1,4 +1,4 @@ -import { toast } from '@session/ui/lib/sonner'; +import { toast } from '@session/ui/lib/toast'; import { useId } from 'react'; export type ToastErrorRefetchProps = { diff --git a/apps/staking/components/WalletModalButtonWithLocales.tsx b/apps/staking/components/WalletModalButtonWithLocales.tsx index c1e28963..f021959c 100644 --- a/apps/staking/components/WalletModalButtonWithLocales.tsx +++ b/apps/staking/components/WalletModalButtonWithLocales.tsx @@ -1,3 +1,5 @@ +'use client'; + import WalletModalButton, { WalletModalButtonProps, } from '@session/wallet/components/WalletModalButton'; @@ -17,7 +19,7 @@ export function WalletModalButtonWithLocales({ disconnected: dictionary('connect'), connected: dictionary('connected'), connecting: dictionary('connecting'), - reconnecting: dictionary('reconnecting'), + reconnecting: dictionary('connecting'), } } ariaLabels={ diff --git a/apps/staking/components/WalletNetworkDropdownWithLocales.tsx b/apps/staking/components/WalletNetworkDropdownWithLocales.tsx index d1d92b76..b08295c1 100644 --- a/apps/staking/components/WalletNetworkDropdownWithLocales.tsx +++ b/apps/staking/components/WalletNetworkDropdownWithLocales.tsx @@ -5,7 +5,7 @@ import WalletNetworkDropdown from '@session/wallet/components/WalletNetworkDropd import { useTranslations } from 'next-intl'; import { useWallet } from '@session/wallet/hooks/wallet-hooks'; import { SwitchChainErrorType } from 'viem'; -import { toast } from '@session/ui/lib/sonner'; +import { toast } from '@session/ui/lib/toast'; export function WalletNetworkDropdownWithLocales({ className }: { className?: string }) { const { isConnected } = useWallet(); diff --git a/apps/staking/hooks/useClaimRewards.tsx b/apps/staking/hooks/useClaimRewards.tsx index 891856d5..2643fa30 100644 --- a/apps/staking/hooks/useClaimRewards.tsx +++ b/apps/staking/hooks/useClaimRewards.tsx @@ -1,169 +1,18 @@ 'use client'; -import { TOAST } from '@/lib/constants'; import { useClaimRewardsQuery, useUpdateRewardsBalanceQuery, type UseUpdateRewardsBalanceQueryParams, } from '@session/contracts/hooks/ServiceNodeRewards'; import { useEffect, useMemo, useState } from 'react'; -import { toast } from '@session/ui/lib/sonner'; -import { collapseString } from '@session/util/string'; -import type { SimulateContractErrorType, WriteContractErrorType } from 'viem'; -import { isProduction } from '@/lib/env'; import { useTranslations } from 'next-intl'; -import type { - GenericContractStatus, - WriteContractStatus, -} from '@session/contracts/hooks/useContractWriteQuery'; +import { getContractErrorName } from '@session/contracts'; -export enum CLAIM_REWARDS_STATE { - SIMULATE_UPDATE_BALANCE, - WRITE_UPDATE_BALANCE, - TRANSACTION_UPDATE_BALANCE, - SIMULATE_CLAIM, - WRITE_CLAIM, - TRANSACTION_CLAIM, -} - -const useClaimRewardsStage = ({ - updateBalanceSimulateStatus, - updateBalanceWriteStatus, - updateBalanceTransactionStatus, - claimSimulateStatus, - claimWriteStatus, - claimTransactionStatus, - skipUpdateBalance, -}: { - updateBalanceSimulateStatus: GenericContractStatus; - updateBalanceWriteStatus: WriteContractStatus; - updateBalanceTransactionStatus: GenericContractStatus; - claimSimulateStatus: GenericContractStatus; - claimWriteStatus: WriteContractStatus; - claimTransactionStatus: GenericContractStatus; - skipUpdateBalance: boolean; -}) => { - const stage = useMemo(() => { - if ( - (skipUpdateBalance || - (updateBalanceSimulateStatus === 'success' && - updateBalanceWriteStatus === 'success' && - updateBalanceTransactionStatus === 'success')) && - claimSimulateStatus === 'success' && - claimWriteStatus === 'success' && - claimTransactionStatus === 'success' - ) { - return CLAIM_REWARDS_STATE.TRANSACTION_CLAIM; - } - - if ( - (skipUpdateBalance || - (updateBalanceSimulateStatus === 'success' && - updateBalanceWriteStatus === 'success' && - updateBalanceTransactionStatus === 'success')) && - claimSimulateStatus === 'success' && - claimWriteStatus === 'success' && - claimTransactionStatus !== 'success' - ) { - return CLAIM_REWARDS_STATE.SIMULATE_CLAIM; - } - - if ( - (skipUpdateBalance || - (updateBalanceSimulateStatus === 'success' && - updateBalanceWriteStatus === 'success' && - updateBalanceTransactionStatus === 'success')) && - claimSimulateStatus === 'success' && - claimWriteStatus !== 'success' && - claimTransactionStatus !== 'success' - ) { - return CLAIM_REWARDS_STATE.WRITE_CLAIM; - } - - if ( - (skipUpdateBalance || - (updateBalanceSimulateStatus === 'success' && - updateBalanceWriteStatus === 'success' && - updateBalanceTransactionStatus === 'success')) && - claimSimulateStatus !== 'success' && - claimWriteStatus !== 'success' && - claimTransactionStatus !== 'success' - ) { - return CLAIM_REWARDS_STATE.SIMULATE_CLAIM; - } - - if ( - updateBalanceSimulateStatus === 'success' && - updateBalanceWriteStatus === 'success' && - updateBalanceTransactionStatus !== 'success' && - claimSimulateStatus !== 'success' && - claimWriteStatus !== 'success' && - claimTransactionStatus !== 'success' - ) { - return CLAIM_REWARDS_STATE.TRANSACTION_UPDATE_BALANCE; - } - - if ( - updateBalanceSimulateStatus === 'success' && - updateBalanceWriteStatus !== 'success' && - updateBalanceTransactionStatus !== 'success' && - claimSimulateStatus !== 'success' && - claimWriteStatus !== 'success' && - claimTransactionStatus !== 'success' - ) { - return CLAIM_REWARDS_STATE.WRITE_UPDATE_BALANCE; - } - - if ( - updateBalanceSimulateStatus !== 'success' && - updateBalanceWriteStatus !== 'success' && - updateBalanceTransactionStatus !== 'success' && - claimSimulateStatus !== 'success' && - claimWriteStatus !== 'success' && - claimTransactionStatus !== 'success' - ) { - return CLAIM_REWARDS_STATE.SIMULATE_UPDATE_BALANCE; - } - return CLAIM_REWARDS_STATE.SIMULATE_UPDATE_BALANCE; - }, [ - updateBalanceSimulateStatus, - updateBalanceWriteStatus, - updateBalanceTransactionStatus, - claimSimulateStatus, - claimWriteStatus, - claimTransactionStatus, - skipUpdateBalance, - ]); - - const subStage = useMemo(() => { - switch (stage) { - case CLAIM_REWARDS_STATE.SIMULATE_UPDATE_BALANCE: - return updateBalanceSimulateStatus; - case CLAIM_REWARDS_STATE.WRITE_UPDATE_BALANCE: - return updateBalanceWriteStatus; - case CLAIM_REWARDS_STATE.TRANSACTION_UPDATE_BALANCE: - return updateBalanceTransactionStatus; - case CLAIM_REWARDS_STATE.SIMULATE_CLAIM: - return claimSimulateStatus; - case CLAIM_REWARDS_STATE.WRITE_CLAIM: - return claimWriteStatus; - case CLAIM_REWARDS_STATE.TRANSACTION_CLAIM: - return claimTransactionStatus; - default: - return 'pending'; - } - }, [ - stage, - updateBalanceSimulateStatus, - updateBalanceWriteStatus, - updateBalanceTransactionStatus, - claimSimulateStatus, - claimWriteStatus, - claimTransactionStatus, - ]); - - return { stage, subStage }; -}; +import { + formatAndHandleLocalizedContractErrorMessages, + parseContractStatusToProgressStatus, +} from '@/lib/contracts'; type UseClaimRewardsParams = UseUpdateRewardsBalanceQueryParams; @@ -176,19 +25,16 @@ export default function useClaimRewards({ const [enabled, setEnabled] = useState(false); const [skipUpdateBalance, setSkipUpdateBalance] = useState(false); - const dictionary = useTranslations('modules.claim.stage'); - const dictionaryFee = useTranslations('modules.claim.dialog.alert'); + const stageDictKey = 'modules.claim.stage' as const; + const dictionary = useTranslations(stageDictKey); + const dictionaryGeneral = useTranslations('general'); const { updateRewardsBalance, fee: updateBalanceFee, - gasPrice: updateBalanceGasPrice, - gasAmountEstimate: updateBalanceGasAmountEstimate, estimateContractWriteFee: updateBalanceEstimateContractWriteFee, refetchContractWriteFeeEstimate: updateBalanceRefetchContractWriteFeeEstimate, - estimateFeeStatus: updateBalanceEstimateFeeStatus, - simulateStatus: updateBalanceSimulateStatus, - writeStatus: updateBalanceWriteStatus, + contractCallStatus: updateBalanceContractCallStatus, transactionStatus: updateBalanceTransactionStatus, estimateFeeError: updateBalanceEstimateFeeError, simulateError: updateBalanceSimulateError, @@ -199,29 +45,23 @@ export default function useClaimRewards({ const { claimRewards, fee: claimFee, - gasPrice: claimGasPrice, - gasAmountEstimate: claimGasAmountEstimate, estimateContractWriteFee: claimEstimateContractWriteFee, refetchContractWriteFeeEstimate: claimRefetchContractWriteFeeEstimate, - estimateFeeStatus: claimEstimateFeeStatus, - simulateStatus: claimSimulateStatus, - writeStatus: claimWriteStatus, - transactionStatus: claimTransactionStatus, - estimateFeeError: claimEstimateFeeError, + contractCallStatus: claimContractCallStatus, simulateError: claimSimulateError, writeError: claimWriteError, transactionError: claimTransactionError, } = useClaimRewardsQuery(); - const { stage, subStage } = useClaimRewardsStage({ - updateBalanceSimulateStatus, - updateBalanceWriteStatus, - updateBalanceTransactionStatus, - claimSimulateStatus, - claimWriteStatus, - claimTransactionStatus, - skipUpdateBalance, - }); + const updateRewardsBalanceStatus = useMemo( + () => parseContractStatusToProgressStatus(updateBalanceContractCallStatus), + [updateBalanceContractCallStatus] + ); + + const claimRewardsStatus = useMemo( + () => parseContractStatusToProgressStatus(claimContractCallStatus), + [claimContractCallStatus] + ); const estimateFee = () => { updateBalanceEstimateContractWriteFee(); @@ -240,101 +80,61 @@ export default function useClaimRewards({ } }; + const updateRewardsBalanceErrorMessage = useMemo( + () => + formatAndHandleLocalizedContractErrorMessages({ + parentDictKey: stageDictKey, + errorGroupDictKey: 'balance', + dictionary, + dictionaryGeneral, + simulateError: updateBalanceSimulateError, + writeError: updateBalanceWriteError, + transactionError: updateBalanceTransactionError, + }), + [updateBalanceSimulateError, updateBalanceWriteError, updateBalanceTransactionError] + ); + + const claimRewardsErrorMessage = useMemo( + () => + formatAndHandleLocalizedContractErrorMessages({ + parentDictKey: stageDictKey, + errorGroupDictKey: 'claim', + dictionary, + dictionaryGeneral, + simulateError: claimSimulateError, + writeError: claimWriteError, + transactionError: claimTransactionError, + }), + [claimSimulateError, claimWriteError, claimTransactionError] + ); + useEffect(() => { if (enabled && (skipUpdateBalance || updateBalanceTransactionStatus === 'success')) { claimRewards(); } }, [enabled, skipUpdateBalance, updateBalanceTransactionStatus]); - const handleError = (error: Error | SimulateContractErrorType | WriteContractErrorType) => { - console.error(error); - if (error.message && !isProduction) { - toast.error( - collapseString(error.message, TOAST.ERROR_COLLAPSE_LENGTH, TOAST.ERROR_COLLAPSE_LENGTH) - ); - } - }; - - /** - * NOTE: All of these useEffects are required to inform the user of errors via the toaster - */ useEffect(() => { - if (updateBalanceEstimateFeeError) { - // If the gas estimation fails with the RecipientRewardsTooLow error, we can skip the update balance step - // @ts-expect-error -- TODO: Properly type this error - if (updateBalanceEstimateFeeError?.cause?.data?.abiItem?.name === 'RecipientRewardsTooLow') { - setSkipUpdateBalance(true); - } else { - handleError(updateBalanceEstimateFeeError); - toast.error(dictionaryFee('gasFetchFailedUpdateBalance')); - } + // If the gas estimation fails with the RecipientRewardsTooLow error, we can skip the update balance step + if ( + updateBalanceEstimateFeeError && + getContractErrorName(updateBalanceEstimateFeeError) === 'RecipientRewardsTooLow' + ) { + setSkipUpdateBalance(true); } }, [updateBalanceEstimateFeeError]); - useEffect(() => { - if (updateBalanceSimulateError) { - handleError(updateBalanceSimulateError); - toast.error(dictionary('updateBalance.simulate.errorTooltip')); - } - }, [updateBalanceSimulateError]); - - useEffect(() => { - if (updateBalanceWriteError) { - handleError(updateBalanceWriteError); - toast.error(dictionary('updateBalance.write.errorTooltip')); - } - }, [updateBalanceWriteError]); - - useEffect(() => { - if (updateBalanceTransactionError) { - handleError(updateBalanceTransactionError); - toast.error(dictionary('updateBalance.transaction.errorTooltip')); - } - }, [updateBalanceTransactionError]); - - useEffect(() => { - if (claimEstimateFeeError) { - handleError(claimEstimateFeeError); - toast.error(dictionaryFee('gasFetchFailedClaimRewards')); - } - }, [claimEstimateFeeError]); - - useEffect(() => { - if (claimSimulateError) { - handleError(claimSimulateError); - toast.error(dictionary('claimRewards.simulate.errorTooltip')); - } - }, [claimSimulateError]); - - useEffect(() => { - if (claimWriteError) { - handleError(claimWriteError); - toast.error(dictionary('claimRewards.write.errorTooltip')); - } - }, [claimWriteError]); - - useEffect(() => { - if (claimTransactionError) { - handleError(claimTransactionError); - toast.error(dictionary('claimRewards.transaction.errorTooltip')); - } - }, [claimTransactionError]); - return { updateBalanceAndClaimRewards, - estimateFee, refetchFeeEstimate, - updateBalanceFee, - updateBalanceGasPrice, - updateBalanceGasAmountEstimate, claimFee, - claimGasPrice, - claimGasAmountEstimate, - stage, - subStage, + updateBalanceFee, + estimateFee, + updateRewardsBalanceStatus, + claimRewardsStatus, enabled, - updateBalanceEstimateFeeStatus, - claimEstimateFeeStatus, skipUpdateBalance, + updateRewardsBalanceErrorMessage, + claimRewardsErrorMessage, }; } diff --git a/apps/staking/hooks/useExitNode.tsx b/apps/staking/hooks/useExitNode.tsx new file mode 100644 index 00000000..7f91649e --- /dev/null +++ b/apps/staking/hooks/useExitNode.tsx @@ -0,0 +1,71 @@ +import { useMemo } from 'react'; +import { useRemoveBLSPublicKeyWithSignature } from '@session/contracts/hooks/ServiceNodeRewards'; +import { useTranslations } from 'next-intl'; +import { + formatAndHandleLocalizedContractErrorMessages, + parseContractStatusToProgressStatus, +} from '@/lib/contracts'; + +type UseExitNodeParams = { + blsPubKey: string; + timestamp: number; + blsSignature: string; + excludedSigners?: Array; +}; + +export default function useExitNode({ + blsPubKey, + timestamp, + blsSignature, + excludedSigners, +}: UseExitNodeParams) { + const stageDictKey = 'nodeCard.staked.requestExit.dialog.stage' as const; + const dictionary = useTranslations(stageDictKey); + const dictionaryGeneral = useTranslations('general'); + + const { + removeBLSPublicKeyWithSignature, + fee, + estimateContractWriteFee, + contractCallStatus, + simulateError, + writeError, + transactionError, + simulateEnabled, + resetContract, + } = useRemoveBLSPublicKeyWithSignature({ + blsPubKey, + timestamp, + blsSignature, + excludedSigners, + }); + + const errorMessage = useMemo( + () => + formatAndHandleLocalizedContractErrorMessages({ + parentDictKey: stageDictKey, + errorGroupDictKey: 'arbitrum', + dictionary, + dictionaryGeneral, + simulateError, + writeError, + transactionError, + }), + [simulateError, writeError, transactionError] + ); + + const status = useMemo( + () => parseContractStatusToProgressStatus(contractCallStatus), + [contractCallStatus] + ); + + return { + removeBLSPublicKeyWithSignature, + fee, + estimateContractWriteFee, + simulateEnabled, + resetContract, + errorMessage, + status, + }; +} diff --git a/apps/staking/hooks/useRegisterNode.tsx b/apps/staking/hooks/useRegisterNode.tsx index 7002c9cb..9a8b8f89 100644 --- a/apps/staking/hooks/useRegisterNode.tsx +++ b/apps/staking/hooks/useRegisterNode.tsx @@ -2,134 +2,54 @@ import { addresses } from '@session/contracts'; import { useProxyApproval } from '@session/contracts/hooks/SENT'; -import { SESSION_NODE, TOAST } from '@/lib/constants'; import { useAddBLSPubKey } from '@session/contracts/hooks/ServiceNodeRewards'; import { useEffect, useMemo, useState } from 'react'; -import { toast } from '@session/ui/lib/sonner'; -import { collapseString } from '@session/util/string'; -import type { SimulateContractErrorType, WriteContractErrorType } from 'viem'; -import { isProduction } from '@/lib/env'; +import { + formatAndHandleLocalizedContractErrorMessages, + parseContractStatusToProgressStatus, +} from '@/lib/contracts'; import { useTranslations } from 'next-intl'; -import type { - GenericContractStatus, - WriteContractStatus, -} from '@session/contracts/hooks/useContractWriteQuery'; - -export enum REGISTER_STAGE { - APPROVE, - SIMULATE, - WRITE, - TRANSACTION, - JOIN, -} - -const useRegisterStage = ({ - approveWriteStatus, - addBLSSimulateStatus, - addBLSWriteStatus, - addBLSTransactionStatus, -}: { - approveWriteStatus: WriteContractStatus; - addBLSSimulateStatus: GenericContractStatus; - addBLSWriteStatus: WriteContractStatus; - addBLSTransactionStatus: GenericContractStatus; -}) => { - const stage = useMemo(() => { - if ( - approveWriteStatus === 'success' && - addBLSSimulateStatus === 'success' && - addBLSWriteStatus === 'success' && - addBLSTransactionStatus === 'success' - ) { - return REGISTER_STAGE.JOIN; - } - - if ( - approveWriteStatus === 'success' && - addBLSSimulateStatus === 'success' && - addBLSWriteStatus === 'success' && - addBLSTransactionStatus !== 'success' - ) { - return REGISTER_STAGE.TRANSACTION; - } - - if ( - approveWriteStatus === 'success' && - addBLSSimulateStatus === 'success' && - addBLSWriteStatus !== 'success' && - addBLSTransactionStatus !== 'success' - ) { - return REGISTER_STAGE.WRITE; - } - - if ( - approveWriteStatus === 'success' && - addBLSSimulateStatus !== 'success' && - addBLSWriteStatus !== 'success' && - addBLSTransactionStatus !== 'success' - ) { - return REGISTER_STAGE.SIMULATE; - } - - if ( - approveWriteStatus !== 'success' && - addBLSSimulateStatus !== 'success' && - addBLSWriteStatus !== 'success' && - addBLSTransactionStatus !== 'success' - ) { - return REGISTER_STAGE.APPROVE; - } - return REGISTER_STAGE.APPROVE; - }, [approveWriteStatus, addBLSSimulateStatus, addBLSWriteStatus, addBLSTransactionStatus]); - - const subStage = useMemo(() => { - switch (stage) { - case REGISTER_STAGE.APPROVE: - return approveWriteStatus; - case REGISTER_STAGE.SIMULATE: - return addBLSSimulateStatus; - case REGISTER_STAGE.WRITE: - return addBLSWriteStatus; - case REGISTER_STAGE.TRANSACTION: - return addBLSTransactionStatus; - default: - return 'pending'; - } - }, [stage, approveWriteStatus, addBLSSimulateStatus, addBLSWriteStatus, addBLSTransactionStatus]); - - return { stage, subStage }; -}; export default function useRegisterNode({ blsPubKey, blsSignature, nodePubKey, userSignature, + stakeAmount, }: { blsPubKey: string; blsSignature: string; nodePubKey: string; userSignature: string; + stakeAmount: bigint; }) { const [enabled, setEnabled] = useState(false); - const dictionary = useTranslations('actionModules.register.stage'); + + const stageDictKey = 'actionModules.register.stage' as const; + const dictionary = useTranslations(stageDictKey); + const dictionaryGeneral = useTranslations('general'); + const { approve, - status: approveWriteStatus, - error: approveWriteError, + approveWrite, + resetApprove, + status: approveWriteStatusRaw, + readStatus, + writeError: approveWriteError, + simulateError: approveSimulateError, + transactionError: approveTransactionError, } = useProxyApproval({ // TODO: Create network provider to handle network specific logic contractAddress: addresses.ServiceNodeRewards.testnet, - tokenAmount: BigInt(SESSION_NODE.FULL_STAKE_AMOUNT), + tokenAmount: stakeAmount, }); const { addBLSPubKey, - writeStatus: addBLSWriteStatus, - transactionStatus: addBLSTransactionStatus, - simulateStatus: addBLSSimulateStatus, - simulateError, - writeError, + contractCallStatus: addBLSStatusRaw, + simulateError: addBLSSimulateError, + writeError: addBLSWriteError, + transactionError: addBLSTransactionError, } = useAddBLSPubKey({ blsPubKey, blsSignature, @@ -137,62 +57,76 @@ export default function useRegisterNode({ userSignature, }); - const { stage, subStage } = useRegisterStage({ - approveWriteStatus, - addBLSSimulateStatus, - addBLSWriteStatus, - addBLSTransactionStatus, - }); - const registerAndStake = () => { setEnabled(true); approve(); }; - // NOTE: Automatically triggers the write stage once the approval has succeeded - useEffect(() => { - if (enabled && approveWriteStatus === 'success') { - addBLSPubKey(); - } - }, [enabled, approveWriteStatus]); - - const handleError = (error: Error | SimulateContractErrorType | WriteContractErrorType) => { - console.error(error); - if (error.message && !isProduction) { - toast.error( - collapseString(error.message, TOAST.ERROR_COLLAPSE_LENGTH, TOAST.ERROR_COLLAPSE_LENGTH) - ); - } + const resetRegisterAndStake = () => { + if (addBLSStatusRaw !== 'idle') return; + setEnabled(false); + resetApprove(); + approveWrite(); }; - /** - * NOTE: All of these useEffects are required to inform the user of errors via the toaster - */ - useEffect(() => { - if (simulateError) { - handleError(simulateError); - toast.error(dictionary('simulate.errorTooltip')); - } - }, [simulateError]); - - useEffect(() => { - if (approveWriteError) { - handleError(approveWriteError); - toast.error(dictionary('approve.errorTooltip')); - } - }, [approveWriteError]); + const approveErrorMessage = useMemo( + () => + formatAndHandleLocalizedContractErrorMessages({ + parentDictKey: stageDictKey, + errorGroupDictKey: 'approve', + dictionary, + dictionaryGeneral, + simulateError: approveSimulateError, + writeError: approveWriteError, + transactionError: approveTransactionError, + }), + [approveSimulateError, approveWriteError, approveTransactionError] + ); + + const addBLSErrorMessage = useMemo( + () => + formatAndHandleLocalizedContractErrorMessages({ + parentDictKey: stageDictKey, + errorGroupDictKey: 'arbitrum', + dictionary, + dictionaryGeneral, + simulateError: addBLSSimulateError, + writeError: addBLSWriteError, + transactionError: addBLSTransactionError, + }), + [addBLSSimulateError, addBLSWriteError, addBLSTransactionError] + ); + + const allowanceReadStatus = useMemo( + () => parseContractStatusToProgressStatus(readStatus), + [readStatus] + ); + + const approveWriteStatus = useMemo( + () => parseContractStatusToProgressStatus(approveWriteStatusRaw), + [approveWriteStatusRaw] + ); + + const addBLSStatus = useMemo( + () => parseContractStatusToProgressStatus(addBLSStatusRaw), + [addBLSStatusRaw] + ); + // NOTE: Automatically triggers the write stage once the approval has succeeded useEffect(() => { - if (writeError) { - handleError(writeError); - toast.error(dictionary('write.errorTooltip')); + if (enabled && approveWriteStatusRaw === 'success') { + addBLSPubKey(); } - }, [writeError]); + }, [enabled, approveWriteStatusRaw]); return { registerAndStake, - stage, - subStage, + resetRegisterAndStake, + allowanceReadStatus, + approveWriteStatus, + approveErrorMessage, + addBLSErrorMessage, + addBLSStatus, enabled, }; } diff --git a/apps/staking/hooks/useRequestNodeExit.tsx b/apps/staking/hooks/useRequestNodeExit.tsx new file mode 100644 index 00000000..61221aaf --- /dev/null +++ b/apps/staking/hooks/useRequestNodeExit.tsx @@ -0,0 +1,60 @@ +import { useMemo } from 'react'; +import { useInitiateRemoveBLSPublicKey } from '@session/contracts/hooks/ServiceNodeRewards'; +import { useTranslations } from 'next-intl'; +import { + formatAndHandleLocalizedContractErrorMessages, + parseContractStatusToProgressStatus, +} from '@/lib/contracts'; + +type UseRequestNodeExitParams = { + contractId: number; +}; + +export default function useRequestNodeExit({ contractId }: UseRequestNodeExitParams) { + const stageDictKey = 'nodeCard.staked.requestExit.dialog.stage' as const; + const dictionary = useTranslations(stageDictKey); + const dictionaryGeneral = useTranslations('general'); + + const { + initiateRemoveBLSPublicKey, + fee, + estimateContractWriteFee, + contractCallStatus, + simulateError, + writeError, + transactionError, + simulateEnabled, + resetContract, + } = useInitiateRemoveBLSPublicKey({ + contractId, + }); + + const errorMessage = useMemo( + () => + formatAndHandleLocalizedContractErrorMessages({ + parentDictKey: stageDictKey, + errorGroupDictKey: 'arbitrum', + dictionary, + dictionaryGeneral, + simulateError, + writeError, + transactionError, + }), + [simulateError, writeError, transactionError] + ); + + const status = useMemo( + () => parseContractStatusToProgressStatus(contractCallStatus), + [contractCallStatus] + ); + + return { + initiateRemoveBLSPublicKey, + fee, + estimateContractWriteFee, + simulateEnabled, + resetContract, + errorMessage, + status, + }; +} diff --git a/apps/staking/hooks/useSENTBalance.tsx b/apps/staking/hooks/useSENTBalance.tsx index 7ccef92f..e85b13ac 100644 --- a/apps/staking/hooks/useSENTBalance.tsx +++ b/apps/staking/hooks/useSENTBalance.tsx @@ -1,7 +1,6 @@ 'use client'; -import { formatSENT } from '@session/contracts'; -import { useSENTBalanceQuery } from '@session/contracts/hooks/SENT'; +import { formatSENTBigInt, useSENTBalanceQuery } from '@session/contracts/hooks/SENT'; import { useMemo } from 'react'; import { useAccount } from 'wagmi'; @@ -9,7 +8,10 @@ export default function useSENTBalance() { const { address } = useAccount(); const { balance: rawBalance, status, refetch } = useSENTBalanceQuery({ address }); - const formattedBalance = useMemo(() => (rawBalance ? formatSENT(rawBalance) : 0), [rawBalance]); + const formattedBalance = useMemo( + () => (rawBalance ? formatSENTBigInt(rawBalance) : 0), + [rawBalance] + ); return { balance: formattedBalance, status, refetch }; } diff --git a/apps/staking/lib/constants.ts b/apps/staking/lib/constants.ts index 19198973..bf1151a2 100644 --- a/apps/staking/lib/constants.ts +++ b/apps/staking/lib/constants.ts @@ -25,6 +25,7 @@ export enum URL { BUG_BOUNTY_TOS = 'https://token.getsession.org/bug-bounty-terms', SESSION_NODE_SOLO_SETUP_DOCS = 'https://docs.getsession.org/class-is-in-session/session-stagenet-single-contributor-node-setup', REMOVE_TOKEN_FROM_WATCH_LIST = 'https://support.metamask.io/managing-my-tokens/custom-tokens/how-to-remove-a-token/', + NODE_LIQUIDATION_LEARN_MORE = 'https://docs.getsession.org/class-is-in-session/session-stagenet-single-contributor-node-setup#unlocking-your-stake', } export const LANDING_BUTTON_URL = { @@ -32,7 +33,7 @@ export const LANDING_BUTTON_URL = { SECONDARY: URL.BUG_BOUNTY_PROGRAM, }; -export const TOS_LOCKED_PATHS = ['/stake', '/mystakes']; +export const TOS_LOCKED_PATHS = ['/stake', '/mystakes', '/register', '/faucet']; export enum COMMUNITY_DATE { SESSION_TOKEN_COMMUNITY_SNAPSHOT = '2024-06-12', @@ -50,7 +51,7 @@ export const SOCIALS = { export enum FAUCET { MIN_ETH_BALANCE = 0.001, - DRIP = 240, + DRIP = 40_000, } export enum FAUCET_ERROR { @@ -111,14 +112,46 @@ export enum QUERY { } export enum SESSION_NODE { - /** 120 SENT */ - FULL_STAKE_AMOUNT = '120000000000', - /** Average blocks per millisecond (~2 minutes per block) */ - MS_PER_BLOCK = 2 * 60 * 1000, + /** 20,000 SENT */ + FULL_STAKE_AMOUNT = '20000000000000', /** Average millisecond per block (~2 minutes per block) */ - BLOCK_VELOCITY = 2 / 60 / 4 / 1000, + MS_PER_BLOCK = 2 * 60 * 1000, +} + +export enum SESSION_NODE_TIME_STATIC { + /** 2 days in days */ + SMALL_CONTRIBUTOR_EXIT_REQUEST_WAIT_TIME_DAYS = 2, +} + +enum SESSION_NODE_TIME_TESTNET { + /** 1 day in seconds */ + EXIT_REQUEST_TIME_SECONDS = 24 * 60 * 60, + /** 2 hours in seconds (time between exit being available and liquidation being available) */ + EXIT_GRACE_TIME_SECONDS = 2 * 60 * 60, + /** 2 days in seconds */ + DEREGISTRATION_LOCKED_STAKE_SECONDS = 2 * 24 * 60 * 60, } +enum SESSION_NODE_TIME_MAINNET { + /** 14 days in seconds */ + EXIT_REQUEST_TIME_SECONDS = 14 * 24 * 60 * 60, + /** 7 days in seconds (time between exit being available and liquidation being available) */ + EXIT_GRACE_TIME_SECONDS = 7 * 24 * 60 * 60, + /** 30 days in seconds */ + DEREGISTRATION_LOCKED_STAKE_SECONDS = 30 * 24 * 60 * 60, +} + +export const SESSION_NODE_TIME = (chain: CHAIN) => { + switch (chain) { + case CHAIN.TESTNET: + return SESSION_NODE_TIME_TESTNET; + + default: + case CHAIN.MAINNET: + return SESSION_NODE_TIME_MAINNET; + } +}; + export enum TOAST { ERROR_COLLAPSE_LENGTH = 128, } diff --git a/apps/staking/lib/contracts.ts b/apps/staking/lib/contracts.ts new file mode 100644 index 00000000..02911ed2 --- /dev/null +++ b/apps/staking/lib/contracts.ts @@ -0,0 +1,112 @@ +import type { useTranslations } from 'next-intl'; +import type { SimulateContractErrorType, TransactionExecutionErrorType } from 'viem'; +import type { WriteContractErrorType } from 'wagmi/actions'; +import { getContractErrorName } from '@session/contracts'; +import { toast } from '@session/ui/lib/toast'; +import { GenericContractStatus, WriteContractStatus } from '@session/contracts/hooks/useContractWriteQuery'; +import { PROGRESS_STATUS } from '@session/ui/motion/progress'; + +/** + * Formats a localized contract error message based on the error type and the dictionary. + * @param dictionary - The dictionary to use for localization. + * @param parentDictKey - The parent dictionary key to use for localization. The key used in `useTranslations` + * @param errorGroupDictKey - The error group dictionary key to use for localization. + * @param error - The error to format. + * @returns The formatted error message. + */ +export const formatLocalizedContractErrorMessage = ({ + dictionary, + parentDictKey, + errorGroupDictKey, + error, +}: { + dictionary: ReturnType; + parentDictKey: string; + errorGroupDictKey: string; + error: Error | SimulateContractErrorType | WriteContractErrorType | TransactionExecutionErrorType; +}) => { + const parsedName = getContractErrorName(error); + const dictKey = `${errorGroupDictKey}.errors.${parsedName}`; + /** @ts-expect-error -- We handle the invalid key case in the if statement below */ + let reason = dictionary(dictKey); + if (reason === `${parentDictKey}.${dictKey}`) reason = parsedName; + /** @ts-expect-error -- This key should exist, but this logs an error and returns the key if it doesn't */ + return dictionary(`${errorGroupDictKey}.errorTemplate`, { reason }); +}; + +/** + * Formats a localized contract error messages based on the error types and the dictionary. This issued for all contract errors from a single contract lifecycle. + * @param dictionary - The dictionary to use for localization. + * @param parentDictKey - The parent dictionary key to use for localization. The key used in `useTranslations` + * @param errorGroupDictKey - The error group dictionary key to use for localization. + * @param error - The error to format. + * @returns The formatted error message. + */ +export const formatAndHandleLocalizedContractErrorMessages = ({ + dictionary, + dictionaryGeneral, + parentDictKey, + errorGroupDictKey, + simulateError, + writeError, + transactionError, +}: { + dictionary: ReturnType; + dictionaryGeneral: ReturnType; + parentDictKey: string; + errorGroupDictKey: string; + simulateError?: SimulateContractErrorType | Error | null; + writeError?: WriteContractErrorType | Error | null; + transactionError?: TransactionExecutionErrorType | Error | null; +}) => { + if (simulateError) { + toast.handleError(simulateError); + return formatLocalizedContractErrorMessage({ + dictionary, + parentDictKey, + errorGroupDictKey, + error: simulateError, + }); + } else if (writeError) { + toast.handleError(writeError); + return formatLocalizedContractErrorMessage({ + dictionary, + parentDictKey, + errorGroupDictKey, + error: writeError, + }); + } else if (transactionError) { + toast.handleError(transactionError); + return formatLocalizedContractErrorMessage({ + dictionary, + parentDictKey, + errorGroupDictKey, + error: transactionError, + }); + } + return dictionaryGeneral('unknownError'); +}; + +/** + * Parses the contract status to a progress status. + * @param contractStatus - The contract status to parse. + * @returns The progress status. + * {@link PROGRESS_STATUS} + */ +export const parseContractStatusToProgressStatus = ( + contractStatus: GenericContractStatus | WriteContractStatus +) => { + switch (contractStatus) { + case 'error': + return PROGRESS_STATUS.ERROR; + + case 'pending': + return PROGRESS_STATUS.PENDING; + + case 'success': + return PROGRESS_STATUS.SUCCESS; + + default: + return PROGRESS_STATUS.IDLE; + } +}; diff --git a/apps/staking/lib/feature-flags.ts b/apps/staking/lib/feature-flags.ts index d808673a..22efeadd 100644 --- a/apps/staking/lib/feature-flags.ts +++ b/apps/staking/lib/feature-flags.ts @@ -5,9 +5,12 @@ export enum EXPERIMENTAL_FEATURE_FLAG { } export enum REMOTE_FEATURE_FLAG { - DISABLE_NODE_REGISTRATION = 'remote_disableNodeRegistration', CUSTOM_BANNER = 'remote_customBanner', NEW_TOKEN_CONTRACT = 'remote_newTokenContract', + DISABLE_NODE_REGISTRATION = 'remote_disableNodeRegistration', + DISABLE_CLAIM_REWARDS = 'remote_disableClaimRewards', + DISABLE_REQUEST_NODE_EXIT = 'remote_disableRequestNodeExit', + DISABLE_NODE_EXIT = 'remote_disableNodeExit', } export const remoteFeatureFlagsInfo: Record< @@ -18,6 +21,18 @@ export const remoteFeatureFlagsInfo: Record< name: 'Disable Node Registration', description: 'Disable the ability to register a node.', }, + [REMOTE_FEATURE_FLAG.DISABLE_CLAIM_REWARDS]: { + name: 'Disable Claim Rewards', + description: 'Disable the ability to claim rewards.', + }, + [REMOTE_FEATURE_FLAG.DISABLE_REQUEST_NODE_EXIT]: { + name: 'Disable Node Exit Request', + description: 'Disable the ability to request a nodes exit.', + }, + [REMOTE_FEATURE_FLAG.DISABLE_NODE_EXIT]: { + name: 'Disable Node Exit', + description: 'Disable the ability to exit a node from the network.', + }, [REMOTE_FEATURE_FLAG.CUSTOM_BANNER]: { name: 'Custom Banner', description: 'Use a custom with custom text.', diff --git a/apps/staking/lib/locale-client.ts b/apps/staking/lib/locale-client.ts index 290a7ce7..45755a38 100644 --- a/apps/staking/lib/locale-client.ts +++ b/apps/staking/lib/locale-client.ts @@ -1,13 +1,14 @@ 'use client'; import { - FormatDistanceStrictOptions, - FormatDistanceToNowStrictOptions, formatDistanceStrict, + FormatDistanceStrictOptions, formatDistanceToNowStrict, + FormatDistanceToNowStrictOptions, } from 'date-fns'; import { useLocale as _useLocale } from 'next-intl'; import { getDateFnsLocale, type Locale } from './locale-util'; +import { getDateFromUnixTimestampSeconds } from '@session/util/date'; const useLocale = (): Locale => _useLocale() as Locale; @@ -34,6 +35,12 @@ export const formatLocalizedRelativeTimeToNowClient = ( }); }; +export const formatLocalizedTimeFromSeconds = ( + seconds: number, + options?: Omit +) => + formatLocalizedRelativeTimeClient(getDateFromUnixTimestampSeconds(seconds), new Date(0), options); + export const formatNumber = (num: number, options: Intl.NumberFormatOptions) => { const locale = useLocale(); return new Intl.NumberFormat(locale, options).format(num); diff --git a/apps/staking/lib/locale-defaults.tsx b/apps/staking/lib/locale-defaults.tsx index 262405d9..9fec1a95 100644 --- a/apps/staking/lib/locale-defaults.tsx +++ b/apps/staking/lib/locale-defaults.tsx @@ -4,11 +4,11 @@ import { cn } from '@session/ui/lib/utils'; import { RichTranslationValues } from 'next-intl'; import Link from 'next/link'; import type { ReactNode } from 'react'; -import { FAUCET, NETWORK, SOCIALS, TICKER, URL } from './constants'; +import { FAUCET, NETWORK, SESSION_NODE_TIME_STATIC, SOCIALS, TICKER, URL } from './constants'; export const internalLink = (href: string, prefetch?: boolean) => { return (children: ReactNode) => ( - + {children} ); @@ -77,6 +77,7 @@ export const defaultTranslationElements = { 'Snapshot', 'text-session-green' ), + 'my-stakes-link': internalLink('/mystakes'), } satisfies RichTranslationValues; export const defaultTranslationVariables = { @@ -91,6 +92,8 @@ export const defaultTranslationVariables = { faucetDrip: FAUCET.DRIP, oxenProgram: 'Oxen Service Node Bonus program', notFoundContentType: 'page', + smallContributorLeaveRequestDelay: + SESSION_NODE_TIME_STATIC.SMALL_CONTRIBUTOR_EXIT_REQUEST_WAIT_TIME_DAYS, } satisfies RichTranslationValues; export const defaultTranslationValues: RichTranslationValues = { diff --git a/apps/staking/lib/queries/getNode.ts b/apps/staking/lib/queries/getNode.ts index 62113e57..0e3d8a6d 100644 --- a/apps/staking/lib/queries/getNode.ts +++ b/apps/staking/lib/queries/getNode.ts @@ -1,5 +1,6 @@ import { parseSessionNodeData } from '@/app/mystakes/modules/StakedNodesModule'; import { type Contributor, NODE_STATE, type ServiceNode } from '@session/sent-staking-js/client'; +import { StakedNode } from '@/components/StakedNodeCard'; type ExplorerResponse> = { id: string; @@ -97,10 +98,11 @@ export async function getNode({ address }: { address: string }) { const node = res.result.service_node_states[0] as ServiceNode | undefined; return node - ? { + ? ({ ...parseSessionNodeData(node, res.result.height), + pubKey: node.service_node_pubkey, state: NODE_STATE.RUNNING, - } + } satisfies StakedNode) : {}; } catch (error) { console.error('Error fetching service nodes:', error); diff --git a/apps/staking/lib/queries/getNodeExitSignatures.ts b/apps/staking/lib/queries/getNodeExitSignatures.ts new file mode 100644 index 00000000..9c3114cb --- /dev/null +++ b/apps/staking/lib/queries/getNodeExitSignatures.ts @@ -0,0 +1,8 @@ +import { SessionStakingClient } from '@session/sent-staking-js/client'; + +export function getNodeExitSignatures( + client: SessionStakingClient, + { nodePubKey }: { nodePubKey: string } +) { + return client.getNodeExitSignatures({ nodePubKey }); +} diff --git a/apps/staking/locales/en.json b/apps/staking/locales/en.json index 5ba03a2c..8b4c823b 100644 --- a/apps/staking/locales/en.json +++ b/apps/staking/locales/en.json @@ -4,11 +4,11 @@ "loading": "Loading...", "or": "or", "and": "and", - "unknownError": "An unknown error occurred.", + "unknownError": "unknown error", "viewOnExplorer": "View on Explorer", "viewOnExplorerShort": "View", "you": "You", - "emptySlot": "EmptyContributorSlot", + "emptySlot": "Empty Contributor Slot", "notFound": "Not Found", "soon": "Soon" }, @@ -163,6 +163,7 @@ }, "banner": { "registrationPaused": "Stagenet node registration is temporarily paused. Apologies for any inconvenience, we expect to be able to re-enable registrations soon. For updates, visit the Session Token discord.", + "claimRewardsDisabled": "Stagenet rewards claim is temporarily paused. Apologies for any inconvenience, we expect to be able to re-enable rewards claiming soon. For updates, visit the Session Token discord.", "newTokenContract": "The Session Token contract has been updated. Remove the old token and add the new token contract to your wallet watch list to continue using the Staking Portal. If you have any questions, visit the Session Token Discord." }, "modules": { @@ -233,59 +234,38 @@ "amountClaimable": "Amount Claimable", "amountClaimableTooltip": "Amount of {tokenSymbol} rewards able to be claimed.", "buttons": { - "submit": "Claim", - "submitAria": "Claim {tokenAmount} {tokenSymbol} with a claim gas fee of {gasAmount} {gasTokenSymbol}" + "submit": "Claim {tokenAmount}", + "submitAria": "Claim {tokenAmount} with a claim gas fee of {gasAmount} {gasTokenSymbol}" }, "alert": { "gasFetchFailedUpdateBalance": "Failed to fetch fee estimate for updating rewards balance. Fee estimate may be inaccurate.", "gasFetchFailedClaimRewards": "Failed to fetch fee estimate for claiming rewards. Fee estimate may be inaccurate.", "highGas": "The price of Gas is higher than usual." - } + }, + "successToast": "Claimed {tokenAmount} on Arbitrum" }, "stage": { - "updateBalance": { - "simulate": { - "pending": "Validating rewards update parameters", - "success": "Validating rewards update parameters", - "error": "Validating rewards update parameters", - "errorTooltip": "Rewards update parameters were malformed, please refresh the page and try again" - }, - "write": { - "pending": "Updating rewards amount on Arbitrum", - "success": "Updating rewards amount on Arbitrum", - "error": "Updating rewards amount on Arbitrum", - "errorTooltip": "Rewards update was not successful on Arbitrum" - }, - "transaction": { - "pending": "Waiting for confirmation of rewards amount on Arbitrum", - "success": "Waiting for confirmation of rewards amount on Arbitrum", - "error": "Waiting for confirmation of rewards amount on Arbitrum", - "errorTooltip": "Rewards update was not successful on Arbitrum" + "balance": { + "idle": "Updating rewards amount on Arbitrum", + "pending": "Updating rewards amount on Arbitrum", + "success": "Updated rewards amount on Arbitrum", + "errorTemplate": "Failed to update rewards amount on Arbitrum, {reason}", + "errors": { + "UserRejectedRequest": "user rejected the request", + "NullAddress": "recipient address is invalid", + "RecipientRewardsTooLow": "rewards balance already updated", + "InvalidBLSSignature": "invalid BLS signature, try again later", + "InsufficientBLSSignatures": "insufficient BLS signatures, try again later" } }, - "claimRewards": { - "simulate": { - "pending": "Validating rewards claim", - "success": "Validating rewards claim", - "error": "Validating rewards claim", - "errorTooltip": "Rewards claim validation failed" - }, - "write": { - "pending": "Submitting rewards claim on Arbitrum", - "success": "Submitting rewards claim on Arbitrum", - "error": "Submitting rewards claim on Arbitrum", - "errorTooltip": "Rewards claim was not successful on Arbitrum" - }, - "transaction": { - "pending": "Waiting for confirmation of rewards claim", - "success": "Waiting for confirmation of rewards claim", - "error": "Waiting for confirmation of rewards claim", - "errorTooltip": "Rewards claim was not successful on Arbitrum" + "claim": { + "idle": "Claiming {tokenAmount} on Arbitrum", + "pending": "Claiming {tokenAmount} on Arbitrum", + "success": "Claimed {tokenAmount} on Arbitrum", + "errorTemplate": "Failed to claim rewards on Arbitrum, {reason}", + "errors": { + "UserRejectedRequest": "user rejected the request" } - }, - "done": { - "pending": "Claim Completed", - "success": "Claim Completed" } } }, @@ -328,45 +308,57 @@ "preparedAtTimestamp": "Registration Prepared", "preparedAtTimestampDescription": "When the node was prepared for registration", "button": { - "submit": "Register and Stake {amount} {tokenSymbol}" + "submit": "Register and Stake {amount}" }, "description": "Please start the registration process from your client or select from the list of open Session Node.", "notFound": { "description": "The registration you are looking for could not be found.", "foundOpenNode": "It looks like this node is already registered and open for staking:", - "foundRunningNode": "It looks like this node is already registered and running:" + "foundRunningNode": "It looks like this node is already registered and running, go to My Stakes to see your stakes:", + "foundRunningNodeOtherOperator": "It looks like this node is already registered and running for another operator:" }, + "notEnoughTokensAlert": "It looks like you don't have enough {tokenSymbol} to stake to this node. It looks like your wallet has {walletAmount}, please make sure you have at least {tokenAmount} in your wallet before registering.", "goToMyStakes": "The Session Node has been successfully registered and joined the network. It will appear on soon", "stage": { - "approve": { - "pending": "Requesting wallet permission to stake {tokenSymbol}", - "success": "Requesting wallet permission to stake {tokenSymbol}", - "error": "Requesting wallet permission to stake {tokenSymbol}", - "errorTooltip": "Wallet permission was not granted" - }, - "simulate": { - "pending": "Validating Session node registration parameters", - "success": "Validating Session node registration parameters", - "error": "Validating Session node registration parameters", - "errorTooltip": "Registration was malformed, please regenerate the registration" + "validate": { + "idle": "Validating registration", + "pending": "Validating registration", + "success": "Registration validated", + "errorTemplate": "Failed to validate registration, {reason}", + "errors": { + "InvalidBLSSignature": "invalid BLS signature, try again later" + } }, - "write": { - "pending": "Requesting Session node registration stake from wallet", - "success": "Requesting Session node registration stake from wallet", - "error": "Requesting Session node registration stake from wallet", - "errorTooltip": "Session node registration stake was not approved" + "approve": { + "idle": "Requesting permission to stake {tokenAmount}", + "pending": "Requesting permission to stake {tokenAmount} waiting for user approval", + "success": "Permission to stake {tokenSymbol} granted", + "errorTemplate": "Wallet permission to stake {tokenSymbol} failed, {reason}, please try again", + "errors": { + "UserRejectedRequest": "user rejected the request" + } }, - "transaction": { - "pending": "Submitting Session node registration to Arbitrum", - "success": "Submitting Session node registration to Arbitrum", - "error": "Submitting Session node registration to Arbitrum", - "errorTooltip": "Session node registration was not sent to Arbitrum" + "arbitrum": { + "idle": "Registering Session node on Arbitrum", + "pending": "Registering Session node on Arbitrum", + "success": "Registered Session node on Arbitrum", + "errorTemplate": "Arbitrum registration failed, {reason}", + "errors": { + "MaxContributorsExceeded": "max contributors exceeded", + "ContributionTotalMismatch": "stake does not match required amount", + "BLSPubkeyAlreadyExists": "node already registered with the same SN Key", + "InvalidBLSProofOfPossession": "BLS proof of possession is invalid", + "MaxPubkeyAggregationsExceeded": "block registration limit exceeded, please try again later", + "SafeERC20FailedOperation": "failed to stake {tokenSymbol}, please try again later" + } }, - "join": { - "pending": "Waiting for Session node to join the network", - "success": "Waiting for Session node to join the network", - "error": "Waiting for Session node to join the network", - "error_tooltip": "Session node was not able to join the network" + "network": { + "idle": "Adding Session node to the Session network", + "pending": "Adding Session node to the Session network waiting for confirmation", + "success": "Adding Session node to the Session network confirmed", + "errors": { + "template": "Session node failed to join the network, {reason}, please try again later" + } } } }, @@ -374,9 +366,11 @@ "title": "Stake to a Node", "contributors": "Shared Contributors", "contributorsTooltip": "The contributors currently staking to this node.", + "feeEstimate": "Fee Estimate", + "feeEstimateTooltip": "The amount of {gasTokenSymbol} this transaction will use. Learn more", "button": { - "submit": "Stake {amount} {tokenSymbol}", - "submitting": "Staking {amount} {tokenSymbol}...", + "submit": "Stake {amount}", + "submitting": "Staking {amount}...", "waitingForWallet": "Waiting for wallet authorization...", "submittingToArbitrum": "Submitting request to Arbitrum...", "success": "Successfully staked {amount} {tokenSymbol}." @@ -405,6 +399,7 @@ }, "staked": { "operator": "Operator", + "operatorTooltip": "You are the operator of this node", "labelExpand": "Expand", "ariaExpand": "Expand node details", "labelCollapse": "Collapse", @@ -413,12 +408,114 @@ "lastUptimeDescription": "The last time the node provided a proof of availability.", "lastRewardHeight": "Last Reward Height: {height}", "lastRewardHeightDescription": "Last block height the node received rewards.", - "unlockingTimerNotification": "Unlocking {time}", - "unlockingTimerDescription": "Time left until the node is unlocked. After unlocking, you can withdraw your stake.", + "requestingExitTimerNotification": "Exit available {time}", + "requestingExitDescription": "Time left until the node is available for exit. Return {relative_time} ({date}) to exit the node.", + "exitTimerNotification": "Exit available. Exit {time} to avoid penalty.", + "exitTimerDescription": "Node is ready for exit. If you don't exit within {time}, you may be subject to a small penalty. Learn more", "deregistrationTimerNotification": "Deregistration {time}", - "deregistrationTimerDescription": "Time left until the node is deregistered and funds are locked for {time}.", + "deregistrationTimerDescription": "Time left until the node is deregistered and funds are locked for {deregistration_locked_stake_time}. Node will be deregistered {relative_time} ({date})", "liquidationNotification": "Awaiting liquidation", - "liquidationDescription": "The node is inactive and waiting to be removed by the network." + "liquidationDescription": "The node is inactive and waiting to be removed by the network.", + "exit": { + "buttonText": "Exit", + "buttonAria": "Exit the node from the network", + "disabledButtonTooltipContent": "Return {relative_time} ({date}) to exit the node.", + "disabledInfo": "Stagenet node exits are temporarily paused. Apologies for any inconvenience, we expect to be re-enable exits soon. For updates, visit the Session Token discord.", + "dialog": { + "title": "Exit Node", + "requestFee": "Exit Fee", + "requestFeeTooltip": "The amount of {gasTokenSymbol} this transaction will use. Learn more", + "amountStaked": "Amount Staked", + "amountStakedTooltip": "Amount of {tokenSymbol} staked by you in this node.", + "buttons": { + "submit": "Exit Node", + "submitAria": "Exit the node from the network with a gas fee of {gasAmount} {gasTokenSymbol}" + }, + "alert": { + "gasFetchFailed": "Failed to fetch fee estimate exiting the node. Fee estimate may be inaccurate.", + "highGas": "The price of Gas is higher than usual." + } + }, + "stage": { + "arbitrum": { + "idle": "Session node exit on Arbitrum submitting", + "pending": "Session node exit on Arbitrum submitting", + "success": "Session node exit on Arbitrum submitted", + "errorTemplate": "Failed to submit node exit on Arbitrum, {reason}", + "errors": { + "UserRejectedRequest": "user rejected the request", + "SignatureExpired": "signature expired, try again later", + "BLSPubkeyDoesNotMatch": "BLS public key does not match", + "InvalidBLSSignature": "invalid BLS signature" + } + }, + "network": { + "idle": "Session node network exit confirming", + "pending": "Session node network exit confirming", + "success": "Session node network exit confirmed", + "errorTemplate": "Failed to confirm the node exit with the Session network, {reason}", + "errors": { + "UnableToReachNetwork": "unable to reach the Session network" + } + } + } + }, + "requestExit": { + "buttonText": "Request Exit", + "buttonAria": "Request for the node to be exited", + "disabledInfo": "Stagenet node exit requests are temporarily paused. Apologies for any inconvenience, we expect to be re-enable exit requests soon. For updates, visit the Session Token discord.", + "dialog": { + "title": "Request Exit", + "submitAria": "Request exit for the node {pubKey}", + "description1": "Are you sure?", + "description2": "Your node will be ready to exit the network {request_time}.", + "description3": "You'll need to confirm the exit after {request_time}. If you don't confirm within {exit_time}, you may be subject to a small penalty. Learn more", + "description4": "Once the node has exited the network, you can claim your stake on the staking portal.", + "buttons": { + "submit": "Request Exit", + "submitAria": "Request exit for {pubKey}", + "cancel": "Cancel", + "cancelAria": "Cancel exit request" + }, + "write": { + "requestFee": "Exit request Fee", + "requestFeeTooltip": "The amount of {gasTokenSymbol} this transaction will use. Learn more", + "amountStaked": "Amount Staked", + "amountStakedTooltip": "Amount of {tokenSymbol} staked by you in this node.", + "buttons": { + "submit": "Request Exit", + "submitAria": "Request node exit with a gas fee of {gasAmount} {gasTokenSymbol}" + }, + "alert": { + "gasFetchFailed": "Failed to fetch fee estimate for requesting node exit. Fee estimate may be inaccurate.", + "highGas": "The price of Gas is higher than usual." + } + }, + "stage": { + "arbitrum": { + "idle": "Session node exit request on Arbitrum submitting", + "pending": "Session node exit request on Arbitrum submitting", + "success": "Session node exit request on Arbitrum submitted", + "errorTemplate": "Failed to submit node exit request on Arbitrum, {reason}", + "errors": { + "UserRejectedRequest": "user rejected the request", + "CallerNotContributor": "caller not a node contributor", + "EarlierLeaveRequestMade": "leave request already made for this node", + "SmallContributorLeaveTooEarly": "small contributors can only initiate exit requests after the node has been running for {smallContributorLeaveRequestDelay} days" + } + }, + "network": { + "idle": "Session node exit request confirming", + "pending": "Session node exit request confirming", + "success": "Session node exit request confirmed", + "errorTemplate": "Failed to confirm the submission of the node exit request to the Session network, {reason}", + "errors": { + "UnableToReachNetwork": "unable to reach the Session network" + } + } + } + } + } } } } diff --git a/apps/staking/package.json b/apps/staking/package.json index 79802bbc..d04b56f6 100644 --- a/apps/staking/package.json +++ b/apps/staking/package.json @@ -7,6 +7,7 @@ "build": "next build", "start": "next start", "check-types": "tsc --noEmit", + "check-telemetry": "next telemetry", "lint": "eslint ." }, "dependencies": { diff --git a/apps/staking/scripts/mock-build-env.sh b/apps/staking/scripts/mock-build-env.sh new file mode 100755 index 00000000..073d8427 --- /dev/null +++ b/apps/staking/scripts/mock-build-env.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +export NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=1234567890qwertyuiop +export NEXT_PUBLIC_SENT_STAKING_API_URL=http://localhost:5000/ +export NEXT_PUBLIC_SENT_EXPLORER_API_URL=http://localhost:5001/ +export FAUCET_WALLET_PRIVATE_KEY=0x1234567890123456789012345678901234567890123456789012345678901234 +export FAUCET_DB_SECRET_KEY=1234567890123456789012345678901234567890123456789012345678901234 +export FAUCET_CHAIN=testnet +export NEXTAUTH_URL=http://localhost:3000 +export NEXTAUTH_SECRET=1234567890qwertyuiop +export DISCORD_CLIENT_ID=12345678901234567890 +export DISCORD_CLIENT_SECRET=1234567890qwertyuiop +export TELEGRAM_BOT_TOKEN=1234567890:qwertyuiopasdfghjkl +export NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=1234567890qwertyuiop +export NEXT_PUBLIC_ENV_FLAG=prd \ No newline at end of file diff --git a/apps/staking/testing/data-test-ids.tsx b/apps/staking/testing/data-test-ids.tsx index 118d8b51..2c7ad25f 100644 --- a/apps/staking/testing/data-test-ids.tsx +++ b/apps/staking/testing/data-test-ids.tsx @@ -17,6 +17,12 @@ export enum ButtonDataTestId { Claim_Tokens_Open_Dialog = 'button:claim-tokens-open-dialog', Claim_Tokens_Submit = 'button:claim-tokens-submit', Hide_Prepared_Registration = 'button:hide-prepared-registration', + Staked_Node_Request_Exit = 'button:staked-node-request-exit', + Staked_Node_Exit = 'button:staked-node-exit', + Staked_Node_Request_Exit_Dialog_Submit = 'button:staked-node-request-exit-dialog-submit', + Staked_Node_Request_Exit_Dialog_Cancel = 'button:staked-node-request-exit-dialog-cancel', + Staked_Node_Request_Exit_Write_Dialog_Submit = 'button:staked-node-request-exit-write-dialog-submit', + Staked_Node_Exit_Dialog_Submit = 'button:staked-node-exit-dialog-submit', } export enum SpecialDataTestId { diff --git a/package.json b/package.json index b2dcea91..dc90f3ee 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,18 @@ "name": "websites", "private": true, "scripts": { - "turbo": "DO_NOT_TRACK=1 turbo", + "turbo": "DO_NOT_TRACK=1 NEXT_TELEMETRY_DISABLED=1 turbo", "build": "pnpm turbo build", "start": "pnpm turbo start", "dev": "pnpm turbo dev", "check-types": "pnpm turbo check-types", "lint": "pnpm turbo lint", "test": "pnpm turbo test", + "gh": "pnpm turbo gh", "format": "prettier --write \"**/*.{ts,tsx,md}\"", "engines": "./scripts/engines.sh", "clean:nm": "./scripts/clean-node-modules.sh", + "check-telemetry": "pnpm turbo telemetry && pnpm turbo check-telemetry", "prepare": "husky" }, "devDependencies": { @@ -42,5 +44,5 @@ "yarn": "use pnpm", "npm": "use pnpm" }, - "packageManager": "pnpm@9.1.3+sha512.7c2ea089e1a6af306409c4fc8c4f0897bdac32b772016196c469d9428f1fe2d5a21daf8ad6512762654ac645b5d9136bb210ec9a00afa8dbc4677843ba362ecd" + "packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1" } diff --git a/packages/auth/README.md b/packages/auth/README.md new file mode 100644 index 00000000..a2bfa87d --- /dev/null +++ b/packages/auth/README.md @@ -0,0 +1,8 @@ +# @session/auth + +This package is a collection of utilities for handling third-party authentication +using [NextAuth.js](https://next-auth.js.org/) + +## Getting Started + +You can follow the generic instructions in the root [README.md](../../README.md#getting-started) to get started. diff --git a/packages/contracts/README.md b/packages/contracts/README.md new file mode 100644 index 00000000..d0459047 --- /dev/null +++ b/packages/contracts/README.md @@ -0,0 +1,24 @@ +# @session/contracts + +This package is a Session smart contract js library for interacting with the Session smart +contracts. The smart contracts can be found in the [ +eth-sn-contracts](https://github.com/oxen-io/eth-sn-contracts/) repository. + +## Getting Started + +You can follow the generic instructions in the root [README.md](../../README.md#getting-started) to get started. + +## Development + +The smart contracts can be found in the [eth-sn-contracts](https://github.com/oxen-io/eth-sn-contracts/) repository. +Their ABIs can be found in the `abi` directory. + +### Updating the ABIs + +To update the ABIs, run the following command: + +```shell +pnpm generate +``` + +This will update the ABIs in the `abi` directory. \ No newline at end of file diff --git a/packages/contracts/abis/ServiceNodeContribution.ts b/packages/contracts/abis/ServiceNodeContribution.ts index bc890363..ac2060c0 100644 --- a/packages/contracts/abis/ServiceNodeContribution.ts +++ b/packages/contracts/abis/ServiceNodeContribution.ts @@ -300,39 +300,6 @@ export const ServiceNodeContributionAbi = [ stateMutability: 'nonpayable', type: 'function', }, - { - inputs: [ - { - internalType: 'uint256', - name: 'amount', - type: 'uint256', - }, - { - internalType: 'uint256', - name: 'deadline', - type: 'uint256', - }, - { - internalType: 'uint8', - name: 'v', - type: 'uint8', - }, - { - internalType: 'bytes32', - name: 'r', - type: 'bytes32', - }, - { - internalType: 'bytes32', - name: 's', - type: 'bytes32', - }, - ], - name: 'contributeFundsWithPermit', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, { inputs: [ { @@ -373,66 +340,6 @@ export const ServiceNodeContributionAbi = [ stateMutability: 'nonpayable', type: 'function', }, - { - inputs: [ - { - internalType: 'uint256', - name: 'amount', - type: 'uint256', - }, - { - components: [ - { - internalType: 'uint256', - name: 'sigs0', - type: 'uint256', - }, - { - internalType: 'uint256', - name: 'sigs1', - type: 'uint256', - }, - { - internalType: 'uint256', - name: 'sigs2', - type: 'uint256', - }, - { - internalType: 'uint256', - name: 'sigs3', - type: 'uint256', - }, - ], - internalType: 'struct IServiceNodeRewards.BLSSignatureParams', - name: '_blsSignature', - type: 'tuple', - }, - { - internalType: 'uint256', - name: 'deadline', - type: 'uint256', - }, - { - internalType: 'uint8', - name: 'v', - type: 'uint8', - }, - { - internalType: 'bytes32', - name: 'r', - type: 'bytes32', - }, - { - internalType: 'bytes32', - name: 's', - type: 'bytes32', - }, - ], - name: 'contributeOperatorFundsWithPermit', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, { inputs: [ { @@ -516,6 +423,24 @@ export const ServiceNodeContributionAbi = [ stateMutability: 'view', type: 'function', }, + { + inputs: [], + name: 'getContributions', + outputs: [ + { + internalType: 'address[]', + name: 'addrs', + type: 'address[]', + }, + { + internalType: 'uint256[]', + name: 'contribs', + type: 'uint256[]', + }, + ], + stateMutability: 'view', + type: 'function', + }, { inputs: [], name: 'maxContributors', @@ -680,6 +605,66 @@ export const ServiceNodeContributionAbi = [ stateMutability: 'view', type: 'function', }, + { + inputs: [ + { + components: [ + { + internalType: 'uint256', + name: 'X', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'Y', + type: 'uint256', + }, + ], + internalType: 'struct BN256G1.G1Point', + name: 'newBlsPubkey', + type: 'tuple', + }, + ], + name: 'updateBLSPubkey', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'uint256', + name: 'serviceNodePubkey', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'serviceNodeSignature1', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'serviceNodeSignature2', + type: 'uint256', + }, + { + internalType: 'uint16', + name: 'fee', + type: 'uint16', + }, + ], + internalType: 'struct IServiceNodeRewards.ServiceNodeParams', + name: 'newParams', + type: 'tuple', + }, + ], + name: 'updateServiceNodeParams', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, { inputs: [], name: 'withdrawContribution', diff --git a/packages/contracts/abis/ServiceNodeRewards.ts b/packages/contracts/abis/ServiceNodeRewards.ts index 312994b5..7d3d03b8 100644 --- a/packages/contracts/abis/ServiceNodeRewards.ts +++ b/packages/contracts/abis/ServiceNodeRewards.ts @@ -81,6 +81,11 @@ export const ServiceNodeRewardsAbi = [ name: 'CallerNotContributor', type: 'error', }, + { + inputs: [], + name: 'ClaimThresholdExceeded', + type: 'error', + }, { inputs: [], name: 'ContractAlreadyStarted', @@ -175,6 +180,16 @@ export const ServiceNodeRewardsAbi = [ name: 'InsufficientBLSSignatures', type: 'error', }, + { + inputs: [], + name: 'InsufficientContributors', + type: 'error', + }, + { + inputs: [], + name: 'InsufficientNodes', + type: 'error', + }, { inputs: [], name: 'InvalidBLSProofOfPossession', @@ -211,11 +226,26 @@ export const ServiceNodeRewardsAbi = [ name: 'LeaveRequestTooEarly', type: 'error', }, + { + inputs: [], + name: 'LiquidatorRewardsTooLow', + type: 'error', + }, + { + inputs: [], + name: 'MaxClaimExceeded', + type: 'error', + }, { inputs: [], name: 'MaxContributorsExceeded', type: 'error', }, + { + inputs: [], + name: 'MaxPubkeyAggregationsExceeded', + type: 'error', + }, { inputs: [], name: 'NotInitializing', @@ -223,7 +253,12 @@ export const ServiceNodeRewardsAbi = [ }, { inputs: [], - name: 'NullRecipient', + name: 'NullAddress', + type: 'error', + }, + { + inputs: [], + name: 'NullPublicKey', type: 'error', }, { @@ -248,6 +283,11 @@ export const ServiceNodeRewardsAbi = [ name: 'OwnableUnauthorizedAccount', type: 'error', }, + { + inputs: [], + name: 'PositiveNumberRequirement', + type: 'error', + }, { inputs: [ { @@ -346,6 +386,32 @@ export const ServiceNodeRewardsAbi = [ name: 'BLSNonSignerThresholdMaxUpdated', type: 'event', }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'uint256', + name: 'newValue', + type: 'uint256', + }, + ], + name: 'ClaimCycleUpdated', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'uint256', + name: 'newThreshold', + type: 'uint256', + }, + ], + name: 'ClaimThresholdUpdated', + type: 'event', + }, { anonymous: false, inputs: [ @@ -359,6 +425,19 @@ export const ServiceNodeRewardsAbi = [ name: 'Initialized', type: 'event', }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'uint256', + name: 'newValue', + type: 'uint256', + }, + ], + name: 'LiquidatorRewardRatioUpdated', + type: 'event', + }, { anonymous: false, inputs: [ @@ -524,6 +603,32 @@ export const ServiceNodeRewardsAbi = [ name: 'Paused', type: 'event', }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'uint256', + name: 'newValue', + type: 'uint256', + }, + ], + name: 'PoolShareOfLiquidationRatioUpdated', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'uint256', + name: 'newValue', + type: 'uint256', + }, + ], + name: 'RecipientRatioUpdated', + type: 'event', + }, { anonymous: false, inputs: [ @@ -936,122 +1041,6 @@ export const ServiceNodeRewardsAbi = [ stateMutability: 'nonpayable', type: 'function', }, - { - inputs: [ - { - components: [ - { - internalType: 'uint256', - name: 'X', - type: 'uint256', - }, - { - internalType: 'uint256', - name: 'Y', - type: 'uint256', - }, - ], - internalType: 'struct BN256G1.G1Point', - name: 'blsPubkey', - type: 'tuple', - }, - { - components: [ - { - internalType: 'uint256', - name: 'sigs0', - type: 'uint256', - }, - { - internalType: 'uint256', - name: 'sigs1', - type: 'uint256', - }, - { - internalType: 'uint256', - name: 'sigs2', - type: 'uint256', - }, - { - internalType: 'uint256', - name: 'sigs3', - type: 'uint256', - }, - ], - internalType: 'struct IServiceNodeRewards.BLSSignatureParams', - name: 'blsSignature', - type: 'tuple', - }, - { - components: [ - { - internalType: 'uint256', - name: 'serviceNodePubkey', - type: 'uint256', - }, - { - internalType: 'uint256', - name: 'serviceNodeSignature1', - type: 'uint256', - }, - { - internalType: 'uint256', - name: 'serviceNodeSignature2', - type: 'uint256', - }, - { - internalType: 'uint16', - name: 'fee', - type: 'uint16', - }, - ], - internalType: 'struct IServiceNodeRewards.ServiceNodeParams', - name: 'serviceNodeParams', - type: 'tuple', - }, - { - components: [ - { - internalType: 'address', - name: 'addr', - type: 'address', - }, - { - internalType: 'uint256', - name: 'stakedAmount', - type: 'uint256', - }, - ], - internalType: 'struct IServiceNodeRewards.Contributor[]', - name: 'contributors', - type: 'tuple[]', - }, - { - internalType: 'uint256', - name: 'deadline', - type: 'uint256', - }, - { - internalType: 'uint8', - name: 'v', - type: 'uint8', - }, - { - internalType: 'bytes32', - name: 'r', - type: 'bytes32', - }, - { - internalType: 'bytes32', - name: 's', - type: 'bytes32', - }, - ], - name: 'addBLSPublicKeyWithPermit', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, { inputs: [], name: 'aggregatePubkey', @@ -1160,11 +1149,76 @@ export const ServiceNodeRewardsAbi = [ }, { inputs: [], + name: 'claimCycle', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + ], name: 'claimRewards', outputs: [], stateMutability: 'nonpayable', type: 'function', }, + { + inputs: [], + name: 'claimRewards', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'claimThreshold', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'currentClaimCycle', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'currentClaimTotal', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, { inputs: [], name: 'designatedToken', @@ -1796,6 +1850,71 @@ export const ServiceNodeRewardsAbi = [ stateMutability: 'nonpayable', type: 'function', }, + { + inputs: [ + { + internalType: 'uint256', + name: 'newValue', + type: 'uint256', + }, + ], + name: 'setClaimCycle', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint256', + name: 'newMax', + type: 'uint256', + }, + ], + name: 'setClaimThreshold', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint256', + name: 'newValue', + type: 'uint256', + }, + ], + name: 'setLiquidatorRewardRatio', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint256', + name: 'newValue', + type: 'uint256', + }, + ], + name: 'setPoolShareOfLiquidationRatio', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint256', + name: 'newValue', + type: 'uint256', + }, + ], + name: 'setRecipientRatio', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, { inputs: [ { diff --git a/packages/contracts/hooks/RewardRatePool.tsx b/packages/contracts/hooks/RewardRatePool.tsx index bff1db41..b6ac96e4 100644 --- a/packages/contracts/hooks/RewardRatePool.tsx +++ b/packages/contracts/hooks/RewardRatePool.tsx @@ -8,28 +8,20 @@ import { useChain } from './useChain'; type RewardRate = ReadContractData; export type RewardRateQuery = ContractReadQueryProps & { - /** Get the reward rate */ - getRewardRate: () => void; /** The reward rate */ rewardRate: RewardRate; }; export function useRewardRateQuery(): RewardRateQuery { const chain = useChain(); - const { - data: rewardRate, - readContract, - ...rest - } = useContractReadQuery({ + const { data: rewardRate, ...rest } = useContractReadQuery({ contract: 'RewardRatePool', functionName: 'rewardRate', - startEnabled: true, chain, }); return { rewardRate, - getRewardRate: readContract, ...rest, }; } diff --git a/packages/contracts/hooks/SENT.tsx b/packages/contracts/hooks/SENT.tsx index c2c3cd82..a752a931 100644 --- a/packages/contracts/hooks/SENT.tsx +++ b/packages/contracts/hooks/SENT.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Address } from 'viem'; +import { Address, SimulateContractErrorType, TransactionExecutionErrorType } from 'viem'; import { useAccount } from 'wagmi'; import { ReadContractData } from 'wagmi/query'; import { SENTAbi } from '../abis'; @@ -10,7 +10,11 @@ import { useEffect, useMemo, useState } from 'react'; import { isProduction } from '@session/util/env'; import { formatBigIntTokenValue, formatNumber } from '@session/util/maths'; import { SENT_DECIMALS, SENT_SYMBOL } from '../constants'; -import { useContractWriteQuery, type WriteContractStatus } from './useContractWriteQuery'; +import { + GenericContractStatus, + useContractWriteQuery, + type WriteContractStatus, +} from './useContractWriteQuery'; import { useChain } from './useChain'; import type { CHAIN } from '../chains'; @@ -23,8 +27,6 @@ export const formatSENTNumber = (value?: number, decimals?: number, hideSymbol?: type SENTBalance = ReadContractData; export type SENTBalanceQuery = ContractReadQueryProps & { - /** Get the session token balance */ - getBalance: () => void; /** The session token balance */ balance: SENTBalance; }; @@ -37,21 +39,17 @@ export function useSENTBalanceQuery({ overrideChain?: CHAIN; }): SENTBalanceQuery { const chain = useChain(); - const { - data: balance, - readContract, - ...rest - } = useContractReadQuery({ + + const { data: balance, ...rest } = useContractReadQuery({ contract: 'SENT', functionName: 'balanceOf', - defaultArgs: [address!], - startEnabled: !!address, + args: [address!], + enabled: !!address, chain: overrideChain ?? chain, }); return { balance, - getBalance: readContract, ...rest, }; } @@ -59,8 +57,6 @@ export function useSENTBalanceQuery({ type SENTAllowance = ReadContractData; export type SENTAllowanceQuery = ContractReadQueryProps & { - /** Get the session token allowance */ - getAllowance: () => void; /** The session token allowance for a contract */ allowance: SENTAllowance; }; @@ -72,28 +68,29 @@ export function useAllowanceQuery({ }): SENTAllowanceQuery { const { address } = useAccount(); const chain = useChain(); - const { - data: allowance, - readContract, - ...rest - } = useContractReadQuery({ + const { data: allowance, ...rest } = useContractReadQuery({ contract: 'SENT', functionName: 'allowance', - defaultArgs: [address!, contractAddress], + args: [address!, contractAddress], + enabled: !!address, chain, }); return { allowance, - getAllowance: readContract, ...rest, }; } export type UseProxyApprovalReturn = { approve: () => void; + approveWrite: () => void; + resetApprove: () => void; status: WriteContractStatus; - error: WriteContractErrorType | Error | null; + readStatus: GenericContractStatus; + simulateError: SimulateContractErrorType | Error | null; + writeError: WriteContractErrorType | Error | null; + transactionError: TransactionExecutionErrorType | Error | null; }; export function useProxyApproval({ @@ -104,25 +101,51 @@ export function useProxyApproval({ tokenAmount: bigint; }): UseProxyApprovalReturn { const [hasEnoughAllowance, setHasEnoughAllowance] = useState(false); + const [allowanceReadStatusOverride, setAllowanceReadStatusOverride] = + useState(null); + const chain = useChain(); const { address } = useAccount(); const { allowance, - getAllowance, - status: readStatus, + status: readStatusRaw, + refetch: refetchRaw, } = useAllowanceQuery({ contractAddress, }); - const { simulateAndWriteContract, writeStatus, simulateError, writeError } = - useContractWriteQuery({ - contract: 'SENT', - functionName: 'approve', - chain, - }); + const refetchAllowance = async () => { + setAllowanceReadStatusOverride('pending'); + await refetchRaw(); + setAllowanceReadStatusOverride(null); + }; + + const readStatus = useMemo( + () => allowanceReadStatusOverride ?? readStatusRaw, + [allowanceReadStatusOverride, readStatusRaw] + ); + + const { + simulateAndWriteContract, + resetContract, + contractCallStatus, + simulateError, + writeError, + transactionError, + } = useContractWriteQuery({ + contract: 'SENT', + functionName: 'approve', + chain, + }); const approve = () => { - getAllowance(); + if (allowance) { + void refetchAllowance(); + } + }; + + const resetApprove = () => { + resetContract(); }; const approveWrite = () => { @@ -130,7 +153,7 @@ export function useProxyApproval({ throw new Error('Checking if current allowance is sufficient'); } - if (allowance >= tokenAmount) { + if (tokenAmount > BigInt(0) && allowance >= tokenAmount) { setHasEnoughAllowance(true); if (!isProduction()) { console.debug( @@ -149,23 +172,30 @@ export function useProxyApproval({ } if (!hasEnoughAllowance) { - return writeStatus; + return contractCallStatus; } if (readStatus === 'pending') { - return 'idle'; + return 'pending'; } else { - return writeStatus; + return contractCallStatus; } - }, [readStatus, writeStatus, hasEnoughAllowance]); - - const error = useMemo(() => simulateError ?? writeError, [simulateError, writeError]); + }, [readStatus, contractCallStatus, hasEnoughAllowance]); useEffect(() => { - if (readStatus === 'success') { + if (readStatus === 'success' && tokenAmount > BigInt(0)) { approveWrite(); } - }, [allowance, readStatus]); + }, [readStatus]); - return { approve, status, error }; + return { + approve, + approveWrite, + resetApprove, + status, + readStatus, + simulateError, + writeError, + transactionError, + }; } diff --git a/packages/contracts/hooks/ServiceNodeRewards.tsx b/packages/contracts/hooks/ServiceNodeRewards.tsx index 961c18ab..bcec759e 100644 --- a/packages/contracts/hooks/ServiceNodeRewards.tsx +++ b/packages/contracts/hooks/ServiceNodeRewards.tsx @@ -69,28 +69,20 @@ export function useUpdateRewardsBalanceQuery({ } export type TotalNodesQuery = ContractReadQueryProps & { - /** Update rewards balance */ - getTotalNodes: () => void; /** The total number of nodes */ totalNodes: ReadContractData; }; export function useTotalNodesQuery(): TotalNodesQuery { const chain = useChain(); - const { - data: totalNodes, - readContract, - ...rest - } = useContractReadQuery({ + const { data: totalNodes, ...rest } = useContractReadQuery({ contract: 'ServiceNodeRewards', functionName: 'totalNodes', - startEnabled: true, chain, }); return { totalNodes, - getTotalNodes: readContract, ...rest, }; } @@ -128,6 +120,12 @@ const encodeBlsPubKey = (hex: string) => { if (chunks.length !== 2) { throw new Error(`BLS Pubkey improperly chunked. Expected 2 chunks, got ${chunks.length}`); } + if (typeof X === 'undefined') { + throw new Error(`BLS Pubkey improperly chunked. X is undefined, got ${X}`); + } + if (typeof Y === 'undefined') { + throw new Error(`BLS Pubkey improperly chunked. Y is undefined, got ${Y}`); + } return { X, Y }; }; @@ -137,6 +135,19 @@ const encodeBlsSignature = (hex: string) => { if (chunks.length !== 4) { throw new Error(`BLS Signature improperly chunked. Expected 4 chunks, got ${chunks.length}`); } + if (typeof sigs0 === 'undefined') { + throw new Error(`BLS Signature improperly chunked. sigs0 is undefined, got ${sigs0}`); + } + if (typeof sigs1 === 'undefined') { + throw new Error(`BLS Signature improperly chunked. sigs0 is undefined, got ${sigs1}`); + } + if (typeof sigs2 === 'undefined') { + throw new Error(`BLS Signature improperly chunked. sigs0 is undefined, got ${sigs2}`); + } + if (typeof sigs3 === 'undefined') { + throw new Error(`BLS Signature improperly chunked. sigs0 is undefined, got ${sigs3}`); + } + return { sigs0, sigs1, sigs2, sigs3 }; }; @@ -148,6 +159,9 @@ const encodeED25519PubKey = (hex: string) => { `ED 25519 Public Key improperly chunked. Expected 1 chunk, got ${chunks.length}` ); } + if (typeof pubKey === 'undefined') { + throw new Error(`ED 25519 Public Key improperly chunked. pubKey is undefined, got ${pubKey}`); + } return { pubKey }; }; @@ -159,6 +173,12 @@ const encodeED25519Signature = (hex: string) => { `ED 25519 Signature improperly chunked. Expected 2 chunks, got ${chunks.length}` ); } + if (typeof sigs0 === 'undefined') { + throw new Error(`ED 25519 Signature improperly chunked. sigs0 is undefined, got ${sigs0}`); + } + if (typeof sigs1 === 'undefined') { + throw new Error(`ED 25519 Signature improperly chunked. sigs0 is undefined, got ${sigs1}`); + } return { sigs0, sigs1 }; }; @@ -200,8 +220,6 @@ export function useAddBLSPubKey({ contract: 'ServiceNodeRewards', functionName: 'addBLSPublicKey', chain, - // TODO: update the types to better reflect optional args as default - // @ts-expect-error -- This is fine as the args change once the query is ready to execute. defaultArgs, }); @@ -210,3 +228,66 @@ export function useAddBLSPubKey({ ...rest, }; } + +export type UseInitiateRemoveBLSPublicKeyReturn = ContractWriteQueryProps & { + initiateRemoveBLSPublicKey: () => void; +}; + +export function useInitiateRemoveBLSPublicKey({ + contractId, +}: { + contractId: number; +}): UseInitiateRemoveBLSPublicKeyReturn { + const chain = useChain(); + + const defaultArgs = useMemo(() => [BigInt(contractId ?? 0)] as [bigint], [contractId]); + + const { simulateAndWriteContract, ...rest } = useContractWriteQuery({ + contract: 'ServiceNodeRewards', + functionName: 'initiateRemoveBLSPublicKey', + chain, + defaultArgs, + }); + + return { + initiateRemoveBLSPublicKey: simulateAndWriteContract, + ...rest, + }; +} + +export type UseRemoveBLSPublicKeyWithSignatureReturn = ContractWriteQueryProps & { + removeBLSPublicKeyWithSignature: () => void; +}; + +export function useRemoveBLSPublicKeyWithSignature({ + blsPubKey, + timestamp, + blsSignature, + excludedSigners = [], +}: { + blsPubKey: string; + timestamp: number; + blsSignature: string; + excludedSigners?: Array; +}): UseRemoveBLSPublicKeyWithSignatureReturn { + const chain = useChain(); + const defaultArgs = useMemo(() => { + const encodedBlsPubKey = encodeBlsPubKey(blsPubKey); + const encodedBlsSignature = encodeBlsSignature(blsSignature); + const encodedTimestamp = BigInt(timestamp); + + return [encodedBlsPubKey, encodedTimestamp, encodedBlsSignature, excludedSigners] as const; + }, [blsPubKey, timestamp, blsSignature, excludedSigners]); + + const { simulateAndWriteContract, ...rest } = useContractWriteQuery({ + contract: 'ServiceNodeRewards', + functionName: 'removeBLSPublicKeyWithSignature', + chain, + defaultArgs, + }); + + return { + removeBLSPublicKeyWithSignature: simulateAndWriteContract, + ...rest, + }; +} diff --git a/packages/contracts/hooks/useContractReadQuery.tsx b/packages/contracts/hooks/useContractReadQuery.tsx index c25b2485..9f1c80c5 100644 --- a/packages/contracts/hooks/useContractReadQuery.tsx +++ b/packages/contracts/hooks/useContractReadQuery.tsx @@ -1,5 +1,5 @@ import { QueryObserverResult, RefetchOptions } from '@tanstack/react-query'; -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import type { Abi, ContractFunctionArgs, ContractFunctionName, ReadContractErrorType } from 'viem'; import { useReadContract } from 'wagmi'; import { ReadContractData } from 'wagmi/query'; @@ -8,8 +8,6 @@ import { CHAIN, chains } from '../chains'; import { addresses, type ContractName } from '../constants'; import type { GenericContractStatus } from './useContractWriteQuery'; -export type ReadContractFunction = (args?: Args) => void; - export type ContractReadQueryProps = { /** The status of the read contract */ status: GenericContractStatus; @@ -21,16 +19,14 @@ export type ContractReadQueryProps = { ) => Promise>; }; -export type UseContractRead = ContractReadQueryProps & { - /** Read the contract */ - readContract: ReadContractFunction; +export type UseContractRead = ContractReadQueryProps & { /** The data from the contract */ data: Data; }; export type ContractReadQueryFetchOptions = { - /** Set startEnabled to true to enable automatic fetching when the query mounts or changes query keys. To manually fetch the query, use the readContract method returned from the useContractReadQuery instance. Defaults to false. */ - startEnabled?: boolean; + /** Set enabled to true to enable automatic fetching when the query mounts or changes query keys. To manually fetch the query, use the readContract method returned from the useContractReadQuery instance. Defaults to false. */ + enabled?: boolean; /** Chain the contract is on */ chain: CHAIN; }; @@ -44,39 +40,28 @@ export function useContractReadQuery< >({ contract, functionName, - startEnabled = false, - defaultArgs, + enabled, + args, chain, }: { contract: T; - defaultArgs?: Args; + args?: Args; functionName: FName; -} & ContractReadQueryFetchOptions): UseContractRead { - const [readEnabled, setReadEnabled] = useState(startEnabled); - const [contractArgs, setContractArgs] = useState(defaultArgs); - +} & ContractReadQueryFetchOptions): UseContractRead { const abi = useMemo(() => Contracts[contract], [contract]); const address = useMemo(() => addresses[contract][chain], [contract, chain]); const { data, status, refetch, error } = useReadContract({ - query: { - enabled: readEnabled, - }, address: address, abi: abi as Abi, functionName: functionName, - args: contractArgs as ContractFunctionArgs, + args: args as ContractFunctionArgs, chainId: chains[chain].id, + query: { enabled }, }); - const readContract: ReadContractFunction = (args) => { - if (args) setContractArgs(args); - setReadEnabled(true); - }; - return { data: data as Data, - readContract, status, refetch, error, diff --git a/packages/contracts/hooks/useContractWriteQuery.tsx b/packages/contracts/hooks/useContractWriteQuery.tsx index 783c2244..d45e9e2a 100644 --- a/packages/contracts/hooks/useContractWriteQuery.tsx +++ b/packages/contracts/hooks/useContractWriteQuery.tsx @@ -31,6 +31,8 @@ export type ContractWriteQueryProps = { estimateContractWriteFee: () => void; /** Re-fetch the gas price and units of gas needed to write the contract */ refetchContractWriteFeeEstimate: () => void; + /** Reset the contract write query */ + resetContract: () => void; /** Estimate gas the amount of gas to make the contract write */ gasAmountEstimate: bigint | null; /** The current price of gas */ @@ -43,6 +45,8 @@ export type ContractWriteQueryProps = { writeStatus: WriteContractStatus; /** Status of the contract transaction */ transactionStatus: TransactionContractStatus; + /** Status of the whole contract call */ + contractCallStatus: WriteContractStatus; /** Contract simulation error */ simulateError: Error | SimulateContractErrorType | null; /** Contract write error */ @@ -53,6 +57,8 @@ export type ContractWriteQueryProps = { estimateFeeStatus: WriteContractStatus; /** Estimate fee error */ estimateFeeError: Error | null; + /** If the simulation is enabled */ + simulateEnabled: boolean; }; export type UseContractWrite = ContractWriteQueryProps & { @@ -84,8 +90,16 @@ export function useContractWriteQuery< const [estimateGasEnabled, setEstimateGasEnabled] = useState(false); const [simulateEnabled, setSimulateEnabled] = useState(false); const [contractArgs, setContractArgs] = useState(defaultArgs); + const [simulateStatusOverride, setSimulateStatusOverride] = + useState(null); - const { data: hash, error: writeError, status: writeStatus, writeContract } = useWriteContract(); + const { + data: hash, + error: writeError, + status: writeStatus, + writeContract, + reset, + } = useWriteContract(); const { error: transactionError, status: transactionStatus } = useWaitForTransactionReceipt({ hash, }); @@ -101,15 +115,26 @@ export function useContractWriteQuery< const { data, - status: simulateStatus, + status: simulateStatusRaw, error: simulateError, - refetch, + refetch: refetchRaw, } = useSimulateContract({ ...contractDetails, query: { enabled: simulateEnabled }, chainId: chains[chain].id, }); + const refetchSimulate = async () => { + setSimulateStatusOverride('pending'); + await refetchRaw(); + setSimulateStatusOverride(null); + }; + + const simulateStatus = useMemo( + () => simulateStatusOverride ?? simulateStatusRaw, + [simulateStatusOverride, simulateStatusRaw] + ); + const { estimateGasAmount, gasAmountEstimate, @@ -137,9 +162,28 @@ export function useContractWriteQuery< setSimulateEnabled(true); - void refetch(); + void refetchSimulate(); }; + const resetContract = () => { + setSimulateEnabled(false); + reset(); + }; + + const contractCallStatus = useMemo(() => { + if (!simulateEnabled) return 'idle'; + if (simulateStatus === 'error' || writeStatus === 'error' || transactionStatus === 'error') { + return 'error'; + } else if ( + simulateStatus === 'success' && + writeStatus === 'success' && + transactionStatus === 'success' + ) { + return 'success'; + } + return 'pending'; + }, [simulateEnabled, simulateStatus, writeStatus, transactionStatus]); + useEffect(() => { if (simulateStatus === 'success' && data?.request) { writeContract(data.request); @@ -158,16 +202,19 @@ export function useContractWriteQuery< simulateAndWriteContract, estimateContractWriteFee, refetchContractWriteFeeEstimate, + resetContract, fee, gasAmountEstimate, gasPrice, simulateStatus, writeStatus, transactionStatus, + contractCallStatus, simulateError, writeError, transactionError, estimateFeeStatus, estimateFeeError, + simulateEnabled, }; } diff --git a/packages/contracts/util.ts b/packages/contracts/util.ts index cf708aed..4b790b54 100644 --- a/packages/contracts/util.ts +++ b/packages/contracts/util.ts @@ -1,9 +1,10 @@ -import { formatUnits, parseUnits } from 'viem'; +import { formatUnits, parseUnits, type SimulateContractErrorType, type TransactionExecutionErrorType } from 'viem'; import { SENT_DECIMALS } from './constants'; +import type { WriteContractErrorType } from 'wagmi/actions'; /** * Formats a value of type `bigint` as a string, using the {@link formatUnits} function and the {@link SENT_DECIMALS} constant. - * + * @deprecated - Use {@link formatSENTBigInt} instead. * @param value - The value to be formatted. * @returns The formatted value as a string. */ @@ -19,3 +20,39 @@ export function formatSENT(value: bigint): string { export function parseSENT(value: string): bigint { return parseUnits(value, SENT_DECIMALS); } + +/** + * Get a smart contract error name from a wagmi error. + * @param error - The error to get the name from. + * @returns The error name. + */ +export function getContractErrorName( + error: Error | SimulateContractErrorType | WriteContractErrorType | TransactionExecutionErrorType +) { + let reason = error.name; + + if (error?.cause && typeof error.cause === 'object') { + if ( + 'data' in error.cause && + error.cause.data && + typeof error.cause.data === 'object' && + 'abiItem' in error.cause.data && + error.cause.data.abiItem && + typeof error.cause.data.abiItem === 'object' && + 'name' in error.cause.data.abiItem && + typeof error.cause.data.abiItem.name === 'string' + ) { + reason = error.cause.data.abiItem.name; + } else if ( + 'cause' in error.cause && + typeof error.cause.cause === 'object' && + error.cause.cause && + 'name' in error.cause.cause && + typeof error.cause.cause.name === 'string' + ) { + reason = error.cause.cause.name; + } + } + + return reason.endsWith('Error') ? reason.slice(0, -5) : reason; +} diff --git a/packages/eslint-config/README.md b/packages/eslint-config/README.md new file mode 100644 index 00000000..d93e4b02 --- /dev/null +++ b/packages/eslint-config/README.md @@ -0,0 +1,3 @@ +# @session/eslint-config + +This package is a collection of `eslint` configurations for the Session Web Ecosystem. diff --git a/packages/feature-flags/README.md b/packages/feature-flags/README.md new file mode 100644 index 00000000..282748ca --- /dev/null +++ b/packages/feature-flags/README.md @@ -0,0 +1,8 @@ +# @session/feature-flags + +This package is a feature flags library for [Next.js](https://nextjs.org/) apps. Supporting client, server, and +remote flags. + +## Getting Started + +You can follow the generic instructions in the root [README.md](../../README.md#getting-started) to get started. diff --git a/packages/logger/README.md b/packages/logger/README.md new file mode 100644 index 00000000..88941e30 --- /dev/null +++ b/packages/logger/README.md @@ -0,0 +1,8 @@ +# @session/logger + +This package is an opinionated logging wrapper. It provides a consistent logging interface for the Session Web +Ecosystem. + +## Getting Started + +You can follow the generic instructions in the root [README.md](../../README.md#getting-started) to get started. diff --git a/packages/logger/logger.ts b/packages/logger/logger.ts index a77f5211..f4364289 100644 --- a/packages/logger/logger.ts +++ b/packages/logger/logger.ts @@ -1,6 +1,6 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-explicit-any */ -import { type InitialLog, TimedLog } from './timedLog'; +import { TimedLog, type InitialLog } from './timedLog'; export enum LOG_LEVEL { DEBUG = 'debug', diff --git a/packages/sent-staking-js/README.md b/packages/sent-staking-js/README.md new file mode 100644 index 00000000..a573291d --- /dev/null +++ b/packages/sent-staking-js/README.md @@ -0,0 +1,9 @@ +# @session/sent-staking-js + +This package is a js library for interacting with the Session Token staking +backend. The backend can be found in +the [Session Token Staking Backend](https://github.com/oxen-io/sent-staking-backend/) repository. + +## Getting Started + +You can follow the generic instructions in the root [README.md](../../README.md#getting-started) to get started. diff --git a/packages/sent-staking-js/client.ts b/packages/sent-staking-js/client.ts index d4ab85f2..13a2058b 100644 --- a/packages/sent-staking-js/client.ts +++ b/packages/sent-staking-js/client.ts @@ -54,6 +54,7 @@ export interface GetNodesForWalletResponse { export interface ServiceNode { state: NODE_STATE; service_node_pubkey: string; + contract_id: number; requested_unlock_height: number; active: boolean; funded: boolean; @@ -141,6 +142,18 @@ interface ValidateRegistrationResponse { remaining_min_contribution?: number; } +/** GET /exit/<32 byte pubkey> */ +export interface GetNodeExitSignaturesResponse { + network: NetworkInfo; + bls_exit_response: BlsExitResponse; +} + +/** GET /liquidation/<32 byte pubkey> */ +export interface GetNodeLiquidationResponse { + network: NetworkInfo; + bls_liquidation_response: BlsLiquidationResponse; +} + /** POST /rewards */ export interface GetRewardsClaimSignatureResponse { @@ -156,6 +169,16 @@ export type BlsRewardsResponse = { signature: string; }; +export type BlsExitResponse = { + bls_pubkey: string; + msg_to_sign: string; + non_signer_indices: Array; + signature: string; + timestamp: number; +}; + +export type BlsLiquidationResponse = BlsExitResponse; + /** Client types */ type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; @@ -291,6 +314,30 @@ export class SessionStakingClient { return this.request(options); } + public async getNodeExitSignatures({ + nodePubKey, + }: { + nodePubKey: string; + }): Promise> { + const options: RequestOptions = { + endpoint: `/exit/${nodePubKey}`, + method: 'POST', + }; + return this.request(options); + } + + public async getNodeLiquidation({ + nodePubKey, + }: { + nodePubKey: string; + }): Promise> { + const options: RequestOptions = { + endpoint: `/liquidation/${nodePubKey}`, + method: 'POST', + }; + return this.request(options); + } + /** * Retrieves service nodes associated with the given Oxen wallet address. * @param address Oxen wallet address. diff --git a/packages/sent-staking-js/test.ts b/packages/sent-staking-js/test.ts index 8830e851..8ff6de0d 100644 --- a/packages/sent-staking-js/test.ts +++ b/packages/sent-staking-js/test.ts @@ -31,11 +31,11 @@ const generateWalletAddress = (): string => * Generates a contributor object. * @returns The generated contributor object. */ -const generateContributor = (): Contributor => { +const generateContributor = (address?: string): Contributor => { return { - address: generateWalletAddress(), - amount: BigInt(Math.random() * 1000), - reserved: BigInt(Math.random() * 1000), + address: address ?? generateWalletAddress(), + amount: BigInt(Math.round(Math.random() * 1000)), + reserved: BigInt(Math.round(Math.random() * 1000)), locked_contributions: [], }; }; @@ -53,12 +53,7 @@ const generateContributors = (maxN = 10, userAddress?: string): Contributor[] => () => generateContributor() ); if (userAddress) { - contributors.unshift({ - address: userAddress, - amount: BigInt(Math.random() * 1000), - reserved: BigInt(Math.random() * 1000), - locked_contributions: [], - }); + contributors.unshift(generateContributor(userAddress)); } return contributors; }; @@ -109,6 +104,7 @@ function generateBasicNodeData({ active: true, funded: true, earned_downtime_blocks: 0, + contract_id: 0, service_node_version: [1, 1, 1], contributors: generateContributors(num_contributions, userAddress), total_contributed: 0, @@ -276,9 +272,9 @@ export const generateMockNodeData = ({ block_timestamp: Date.now(), } as never, wallet: { - rewards: BigInt(0), - contract_rewards: BigInt(0), - contract_claimed: BigInt(0), + rewards: BigInt('480000000000'), + contract_rewards: BigInt('240000000000'), + contract_claimed: BigInt('240000000000'), }, }; diff --git a/packages/testing/README.md b/packages/testing/README.md new file mode 100644 index 00000000..cca3b1f5 --- /dev/null +++ b/packages/testing/README.md @@ -0,0 +1,7 @@ +# @session/testing + +This package is a testing utility library. It provides a type interface for consistent data test ids. + +## Getting Started + +You can follow the generic instructions in the root [README.md](../../README.md#getting-started) to get started. diff --git a/packages/typescript-config/README.md b/packages/typescript-config/README.md new file mode 100644 index 00000000..869141cf --- /dev/null +++ b/packages/typescript-config/README.md @@ -0,0 +1,4 @@ +# @session/typescript-config + +This package is a collection of `tsconfig.json` configurations for the Session Web Ecosystem. + diff --git a/packages/ui/.eslintrc.js b/packages/ui/.eslintrc.js new file mode 100644 index 00000000..4f5bbf90 --- /dev/null +++ b/packages/ui/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + extends: ['@session/eslint-config/next.js'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: __dirname, + }, +}; diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 00000000..9df7873f --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,23 @@ +# @session/ui + +This package contains the shared UI components for the Session Web Ecosystem. The UI component library is a collection +of UI components for [Next.js](https://nextjs.org/) apps +and uses +[Tailwind CSS](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), +and [Shadcn/ui](https://ui.shadcn.com/). + +## Getting Started + +You can follow the generic instructions in the root [README.md](../../README.md#getting-started) to get started. + +## Development + +We generate all base UI components using [Shadcn/ui](https://ui.shadcn.com/). + +### Adding a new base UI component + +You can use the following command to add a new [Shadcn/ui](https://ui.shadcn.com/) component: + +```shell +pnpm ui:add +``` diff --git a/packages/ui/components/CopyToClipboardButton.tsx b/packages/ui/components/CopyToClipboardButton.tsx index 698e8bfc..586672fc 100644 --- a/packages/ui/components/CopyToClipboardButton.tsx +++ b/packages/ui/components/CopyToClipboardButton.tsx @@ -1,15 +1,17 @@ -import { forwardRef } from 'react'; +import { type ButtonHTMLAttributes, forwardRef, useState } from 'react'; import { BaseDataTestId, TestingProps } from '../data-test-ids'; import { ClipboardIcon } from '../icons/ClipboardIcon'; -import { toast } from '../lib/sonner'; +import { toast } from '../lib/toast'; import { cn } from '../lib/utils'; import { Button } from './ui/button'; +import { CheckIcon } from 'lucide-react'; +import { Spinner } from '../icons/Spinner'; export interface CopyToClipboardButtonProps - extends React.ButtonHTMLAttributes, + extends ButtonHTMLAttributes, TestingProps { textToCopy: string; - copyToClipboardToastMessage: string; + copyToClipboardToastMessage?: string; onCopyComplete?: () => void; } @@ -19,13 +21,15 @@ export interface CopyToClipboardButtonProps * @param textToCopy The text to be copied to the clipboard. * @param copyToClipboardToastMessage The message to be displayed in the success toast. */ -function copyToClipboard( +async function copyToClipboard( textToCopy: string, - copyToClipboardToastMessage: string, + copyToClipboardToastMessage?: string, onCopyComplete?: () => void ) { - navigator.clipboard.writeText(textToCopy); - toast.success(copyToClipboardToastMessage); + await navigator.clipboard.writeText(textToCopy); + if (copyToClipboardToastMessage) { + toast.success(copyToClipboardToastMessage); + } if (onCopyComplete) { onCopyComplete(); } @@ -33,9 +37,23 @@ function copyToClipboard( const CopyToClipboardButton = forwardRef( ({ textToCopy, copyToClipboardToastMessage, onCopyComplete, className, ...props }, ref) => { + const [isLoading, setIsLoading] = useState(false); + const [isCopied, setIsCopied] = useState(false); + + const handleClick = async () => { + if (isLoading || isCopied) return; + setIsLoading(true); + await copyToClipboard(textToCopy, copyToClipboardToastMessage, onCopyComplete); + setIsCopied(true); + setIsLoading(false); + setTimeout(() => { + setIsCopied(false); + }, 2000); + }; + return ( ); } diff --git a/packages/ui/components/Module.tsx b/packages/ui/components/Module.tsx index 47334cba..3cafd400 100644 --- a/packages/ui/components/Module.tsx +++ b/packages/ui/components/Module.tsx @@ -40,14 +40,14 @@ const innerModuleVariants = cva( '[&>span]:text-3xl [&>*>span]:text-3xl [&>h3]:font-normal [&>*>h3]:font-normal' ), hero: cn( - 'gap-5 hover:brightness-125', + 'gap-3 sm:gap-5 hover:brightness-125', '[&>h3]:text-3xl [&>h3]:font-normal [&>*>h3]:text-2xl [&>*>h3]:font-normal [&>h3]:text-session-white', '[&>span]:text-8xl [&>*>span]:text-8xl [&>span]:text-session-white [&>*>span]:text-session-white' ), }, size: { - default: 'p-6', - lg: 'p-10 py-12', + default: 'p-4 sm:p-6', + lg: 'px-6 sm:px-10 py-8 sm:py-10', }, }, defaultVariants: { @@ -72,7 +72,7 @@ const Module = forwardRef( className={cn( 'relative', innerModuleVariants({ size, variant, className }), - noPadding && 'p-0', + noPadding && 'p-0 sm:p-0', props.onClick && 'hover:bg-session-green hover:text-session-black hover:cursor-pointer' )} ref={ref} @@ -83,7 +83,7 @@ const Module = forwardRef( background: 'url(/images/module-hero.png)', backgroundPositionX: '35%', backgroundPositionY: '35%', - backgroundSize: '135%', + backgroundSize: '150%', } : undefined } diff --git a/packages/ui/components/ModuleGrid.tsx b/packages/ui/components/ModuleGrid.tsx index e450a171..0283a1ba 100644 --- a/packages/ui/components/ModuleGrid.tsx +++ b/packages/ui/components/ModuleGrid.tsx @@ -9,6 +9,8 @@ const moduleGridVariants = cva('module-grid', { grid: 'grid auto-rows-min', section: 'from-[#0A0C0C] to-[#081512] bg-gradient-to-b bg-blend-lighten shadow-md border-[2px] rounded-3xl border-[#54797241] flex flex-col', + action: + 'shadow-md border-[2px] rounded-3xl border-[#668C83] border-opacity-80 flex flex-col overflow-hidden', }, size: { md: 'gap-2 md:gap-4 grid-cols-1 sm:grid-cols-2', @@ -55,7 +57,7 @@ const ModuleGridTitle = forwardRef(
& { leadingChars?: CollapseStringsParams[1]; trailingChars?: CollapseStringsParams[2]; expandOnHover?: boolean; + expandOnHoverDesktopOnly?: boolean; alwaysShowCopyButton?: boolean; force?: 'expand' | 'collapse'; copyToClipboardAriaLabel?: string; @@ -27,6 +28,7 @@ export const PubKey = forwardRef((props, ref) => { leadingChars, trailingChars, expandOnHover, + expandOnHoverDesktopOnly, alwaysShowCopyButton, copyToClipboardAriaLabel, copyToClipboardToastMessage, @@ -68,7 +70,11 @@ export const PubKey = forwardRef((props, ref) => { className={cn( 'select-all break-all', force !== 'collapse' && expandOnHover && 'group-hover:hidden', - force !== 'collapse' && expandOnHover && isExpanded ? 'hidden' : 'block' + force !== 'collapse' && expandOnHover && isExpanded ? 'hidden' : 'block', + force !== 'collapse' && expandOnHoverDesktopOnly && 'md:group-hover:hidden', + force !== 'collapse' && expandOnHoverDesktopOnly && isExpanded + ? 'md:hidden' + : 'md:block' )} > {collapsedPubKey} @@ -79,7 +85,11 @@ export const PubKey = forwardRef((props, ref) => { className={cn( 'select-all break-all', force !== 'collapse' && expandOnHover && 'group-hover:block', - (force !== 'collapse' && expandOnHover && isExpanded) || isExpanded ? 'block' : 'hidden' + (force !== 'collapse' && expandOnHover && isExpanded) || isExpanded ? 'block' : 'hidden', + force !== 'collapse' && expandOnHoverDesktopOnly && 'md:group-hover:block', + (force !== 'collapse' && expandOnHoverDesktopOnly && isExpanded) || isExpanded + ? 'md:block' + : 'md:hidden' )} > {pubKey} diff --git a/packages/ui/components/motion/progress.tsx b/packages/ui/components/motion/progress.tsx new file mode 100644 index 00000000..5262f079 --- /dev/null +++ b/packages/ui/components/motion/progress.tsx @@ -0,0 +1,162 @@ +import { forwardRef, type HTMLAttributes, useMemo } from 'react'; +import { motion } from 'framer-motion'; +import { Circle } from './shapes/circle'; +import { cn } from '../../lib/utils'; + +export enum PROGRESS_STATUS { + IDLE, + PENDING, + SUCCESS, + ERROR, +} + +type Step = { + text: Partial> & { [PROGRESS_STATUS.IDLE]: string }; + status: PROGRESS_STATUS; +}; + +interface ProgressProps extends HTMLAttributes { + steps: Array; +} + +const variants = { + idle: { opacity: 0, pathLength: 0 }, + pending: { opacity: 1, pathLength: 0.5 }, + error: { opacity: 1, pathLength: 0.5 }, + success: { opacity: 1, pathLength: 1 }, +}; + +const circleVariants = { + idle: { scale: 0.75 }, + pending: { scale: 1 }, + error: { scale: 1 }, + success: { scale: 1 }, +}; + +function ProgressStep({ + isFirst, + isLast, + circleRadius, + text, + status, +}: { + isFirst: boolean; + isLast: boolean; + status: PROGRESS_STATUS; + circleRadius: number; + text: Partial> & { [PROGRESS_STATUS.IDLE]: string }; +}) { + const circleVariant = useMemo(() => { + switch (status) { + case PROGRESS_STATUS.IDLE: + return 'grey' as const; + case PROGRESS_STATUS.PENDING: + return 'blue' as const; + case PROGRESS_STATUS.SUCCESS: + return 'green' as const; + case PROGRESS_STATUS.ERROR: + return 'red' as const; + } + }, [status]); + + const statusText = useMemo(() => { + switch (status) { + case PROGRESS_STATUS.IDLE: + return 'idle'; + case PROGRESS_STATUS.PENDING: + return 'pending'; + case PROGRESS_STATUS.SUCCESS: + return 'success'; + case PROGRESS_STATUS.ERROR: + return 'error'; + } + }, [status]); + + const height = circleRadius * 7; + const width = circleRadius * 7; + const x1 = width / 2; + + return ( +
+ + + + + + + + + + + + {text[status] ?? text[PROGRESS_STATUS.IDLE]} + +
+ ); +} + +export const Progress = forwardRef( + ({ steps, className, ...props }, ref) => { + return ( +
+ {steps.map(({ text, status }, i) => ( + + ))} +
+ ); + } +); +Progress.displayName = 'Progress'; diff --git a/packages/ui/components/motion/shapes/circle.tsx b/packages/ui/components/motion/shapes/circle.tsx new file mode 100644 index 00000000..350fa5e1 --- /dev/null +++ b/packages/ui/components/motion/shapes/circle.tsx @@ -0,0 +1,97 @@ +import { + AnimationControls, + motion, + type MotionStyle, + TargetAndTransition, + VariantLabels, + Variants, +} from 'framer-motion'; +import { forwardRef } from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '../../../lib/utils'; + +const circleVariants = cva('', { + variants: { + variant: { + black: 'fill-indicator-black', + grey: 'fill-indicator-grey', + green: 'fill-indicator-green', + blue: 'fill-indicator-blue', + yellow: 'fill-indicator-yellow', + red: 'fill-indicator-red', + }, + strokeVariant: { + black: 'stroke-indicator-black', + grey: 'stroke-indicator-grey', + green: 'stroke-indicator-green', + blue: 'stroke-indicator-blue', + yellow: 'stroke-indicator-yellow', + red: 'stroke-indicator-red', + }, + glow: { + black: '', + grey: 'drop-shadow-[0_0_8px_var(--indicator-grey)] glow-grey', + green: 'drop-shadow-[0_0_8px_var(--indicator-green)] glow', + blue: 'drop-shadow-[0_0_8px_var(--indicator-blue)] glow-blue', + yellow: 'drop-shadow-[0_0_8px_var(--indicator-yellow)] glow-yellow', + red: 'drop-shadow-[0_0_8px_var(--indicator-red)] glow-red', + }, + partial: { + '100': '', + '25': '[stroke-dasharray:25,100] [stroke-linecap:round]', + }, + }, + defaultVariants: { + variant: 'black', + strokeVariant: 'black', + glow: 'black', + partial: '100', + }, +}); + +type CircleVariantProps = VariantProps; + +type CircleProps = CircleVariantProps & { + cx: number | string; + cy: number | string; + r: number; + strokeWidth?: number; + className?: string; + variants?: Variants; + animate?: AnimationControls | TargetAndTransition | VariantLabels; + style?: MotionStyle; +}; + +export const Circle = forwardRef( + ( + { + cx, + cy, + r, + strokeWidth, + className, + style, + variant, + strokeVariant, + partial, + glow, + animate, + variants, + ...props + }, + ref + ) => ( + + ) +); diff --git a/packages/ui/components/ui/alert-dialog.tsx b/packages/ui/components/ui/alert-dialog.tsx index 521d2567..d2cc2a0b 100644 --- a/packages/ui/components/ui/alert-dialog.tsx +++ b/packages/ui/components/ui/alert-dialog.tsx @@ -1,5 +1,6 @@ 'use client'; +import type { ReactNode } from 'react'; import * as React from 'react'; import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; @@ -19,6 +20,8 @@ const AlertDialogTitle = AlertDialogPrimitive.Title; const AlertDialogDescription = AlertDialogPrimitive.Description; +const AlertDialogCancel = AlertDialogPrimitive.Cancel; + const AlertDialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -36,22 +39,22 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; const AlertDialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & { title: string } ->(({ title, className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { dialogTitle: ReactNode } +>(({ dialogTitle, className, children, ...props }, ref) => ( - {title ? ( + {dialogTitle ? ( - {title} + {dialogTitle} ) : null} @@ -77,7 +80,7 @@ AlertDialogHeader.displayName = 'AlertDialogHeader'; const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
); @@ -91,18 +94,6 @@ const AlertDialogAction = React.forwardRef< )); AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; -const AlertDialogCancel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; - export { AlertDialog, AlertDialogPortal, diff --git a/packages/ui/components/ui/button.tsx b/packages/ui/components/ui/button.tsx index 867f0e12..dcb71261 100644 --- a/packages/ui/components/ui/button.tsx +++ b/packages/ui/components/ui/button.tsx @@ -19,6 +19,8 @@ const buttonVariants = cva( 'border border-destructive text-destructive bg-background hover:bg-destructive hover:text-destructive-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', + 'destructive-ghost': + 'text-destructive hover:bg-destructive hover:text-destructive-foreground', link: 'text-primary underline-offset-4 hover:underline', }, size: { diff --git a/packages/ui/components/ui/tooltip.tsx b/packages/ui/components/ui/tooltip.tsx index 774968dc..d151609c 100644 --- a/packages/ui/components/ui/tooltip.tsx +++ b/packages/ui/components/ui/tooltip.tsx @@ -9,8 +9,11 @@ import { type ReactNode, useState, } from 'react'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +/** @ts-ignore TS doesnt know what its talking about */ import { useDebounce } from '@uidotdev/usehooks'; import { cn } from '../../lib/utils'; +import { TriangleAlertIcon } from '../../icons/TriangleAlertIcon'; const TooltipRoot = PopoverPrimitive.Root; @@ -30,7 +33,7 @@ const TooltipContent = forwardRef< sideOffset={sideOffset} side="top" className={cn( - 'text-session-white animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-session-black border-px z-50 overflow-hidden rounded-full border border-[#1C2624] bg-opacity-50 px-4 py-2 text-sm shadow-xl outline-none', + 'text-session-white animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-session-black border-px z-50 max-w-[90svw] overflow-hidden rounded-xl border border-[#1C2624] bg-opacity-50 px-4 py-2 text-sm shadow-xl outline-none md:max-w-lg', className )} {...props} @@ -42,10 +45,11 @@ type TooltipProps = ComponentPropsWithoutRef & disableOnHover?: boolean; tooltipContent: ReactNode; triggerProps?: Omit, 'children'>; + contentProps?: Omit, 'children'>; }; const Tooltip = forwardRef, TooltipProps>( - ({ tooltipContent, children, triggerProps, disableOnHover, ...props }, ref) => { + ({ tooltipContent, children, contentProps, triggerProps, disableOnHover, ...props }, ref) => { const [hovered, setHovered] = useState(false); const [clicked, setClicked] = useState(false); const debouncedHover = useDebounce(hovered, 150); @@ -67,7 +71,7 @@ const Tooltip = forwardRef, TooltipP }; return ( - + , TooltipP onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} ref={ref} - {...props} + {...contentProps} > {tooltipContent} @@ -90,4 +94,13 @@ const Tooltip = forwardRef, TooltipP } ); -export { Tooltip, TooltipRoot, TooltipContent, TooltipTrigger }; +const AlertTooltip = forwardRef< + ElementRef, + Omit +>((props, ref) => ( + + + +)); + +export { Tooltip, TooltipRoot, TooltipContent, TooltipTrigger, AlertTooltip }; diff --git a/packages/ui/data-test-ids.ts b/packages/ui/data-test-ids.ts index 4b98aea0..d9dcb24e 100644 --- a/packages/ui/data-test-ids.ts +++ b/packages/ui/data-test-ids.ts @@ -40,4 +40,5 @@ export const createDataTestId = genericCreateDataTestId; export enum ButtonDataTestId { Copy_Pub_Key_To_Clipboard = 'button:copy-pub-key-to-clipboard', + Copy_Error_To_Clipboard = 'button:copy-error-to-clipboard', } diff --git a/packages/ui/icons/Spinner.tsx b/packages/ui/icons/Spinner.tsx new file mode 100644 index 00000000..864bb80e --- /dev/null +++ b/packages/ui/icons/Spinner.tsx @@ -0,0 +1,8 @@ +import { Loader } from 'lucide-react'; +import { forwardRef } from 'react'; +import { type SVGAttributes } from './types'; +import { cn } from '../lib/utils'; + +export const Spinner = forwardRef(({ className, ...props }, ref) => ( + +)); diff --git a/packages/ui/lib/sonner.ts b/packages/ui/lib/sonner.ts deleted file mode 100644 index 84bdebef..00000000 --- a/packages/ui/lib/sonner.ts +++ /dev/null @@ -1 +0,0 @@ -export { toast } from 'sonner'; diff --git a/packages/ui/lib/toast.tsx b/packages/ui/lib/toast.tsx new file mode 100644 index 00000000..c5da176e --- /dev/null +++ b/packages/ui/lib/toast.tsx @@ -0,0 +1,34 @@ +import { type ExternalToast, toast as sonnerToast } from 'sonner'; +import { collapseString } from '@session/util/string'; +import { CopyToClipboardButton } from '../components/CopyToClipboardButton'; +import { ButtonDataTestId } from '../data-test-ids'; +import React from 'react'; + +type Toast = typeof sonnerToast & { + handleError: (error: Error, data?: ExternalToast) => void; +}; + +/** + * Handles errors by displaying them in a toast in a collapsed form with a copy button for the full error. This will also log the error to the console. + * @param error The error to handle + * @param data The data to pass to the toast + */ +function handleError(error: Error, data?: ExternalToast) { + console.error(error); + const errorMessage = error.message ?? 'An unknown error occurred'; + sonnerToast.error( +
+ {collapseString(error.message, 128, 128)} + +
, + data + ); +} + +export const toast = { + ...sonnerToast, + handleError, +} as Toast; diff --git a/packages/ui/package.json b/packages/ui/package.json index 4c2bd502..50eb8d1a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -7,7 +7,9 @@ "./ui/*": "./components/ui/*.tsx", "./components/*": "./components/*.tsx", "./lib/*": "./lib/*.ts", + "./lib/toast": "./lib/toast.tsx", "./icons/*": "./icons/*.tsx", + "./motion/*": "./components/motion/*.tsx", "./icons/types": "./icons/types.ts", "./taiwind": "./tailwind.config.ts", "./postcss": "./postcss.config.js" @@ -16,7 +18,6 @@ "check-types": "tsc --noEmit", "lint": "eslint .", "lint-staged": "lint-staged", - "component:generate": "turbo gen react-component", "ui:add": "npx shadcn-ui@latest add" }, "devDependencies": { @@ -48,6 +49,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "framer-motion": "^11.5.4", "lucide-react": "^0.376.0", "next": "14.2.5", "next-themes": "^0.3.0", diff --git a/packages/ui/styles/global.css b/packages/ui/styles/global.css index 7f130d5c..37b38352 100644 --- a/packages/ui/styles/global.css +++ b/packages/ui/styles/global.css @@ -82,6 +82,13 @@ --header-vertical-padding: 2rem; --header-displacement: calc(var(--header-height) + var(--header-vertical-padding) * 2); --screen-without-header: calc(100vh - var(--header-displacement)); + + --indicator-black: var(--session-black); + --indicator-grey: rgb(74, 74, 74); + --indicator-green: var(--session-green); + --indicator-blue: rgb(0, 163, 247); + --indicator-yellow: rgb(247, 222, 0); + --indicator-red: rgb(239 68 68); } } diff --git a/packages/ui/tailwind.config.ts b/packages/ui/tailwind.config.ts index ea56c154..bf32f966 100644 --- a/packages/ui/tailwind.config.ts +++ b/packages/ui/tailwind.config.ts @@ -84,6 +84,14 @@ export default { 900: '#043D22', 950: '#022314', }, + indicator: { + black: 'var(--indicator-black)', + grey: 'var(--indicator-grey)', + green: 'var(--indicator-green)', + blue: 'var(--indicator-blue)', + yellow: 'var(--indicator-yellow)', + red: 'var(--indicator-red)', + }, black: 'var(--session-black)', border: 'hsl(var(--border))', input: 'hsl(var(--input))', diff --git a/packages/util/README.md b/packages/util/README.md new file mode 100644 index 00000000..bd0a9860 --- /dev/null +++ b/packages/util/README.md @@ -0,0 +1,7 @@ +# @session/util + +This package is a utility library for common functions. + +## Getting Started + +You can follow the generic instructions in the root [README.md](../../README.md#getting-started) to get started. diff --git a/packages/util/env.ts b/packages/util/env.ts index a5f7a3d5..e93e0b47 100644 --- a/packages/util/env.ts +++ b/packages/util/env.ts @@ -22,7 +22,7 @@ export const getEnvironment = (): Environment => { console.warn(`Invalid environment flag (NEXT_PUBLIC_ENV_FLAG): ${environment}`); return Environment.DEV; } - console.log(`Environment: ${environment}`); + if (environment !== Environment.PRD) console.log(`Environment: ${environment}`); return environment as Environment; }; diff --git a/packages/util/logger.ts b/packages/util/logger.ts index 5046ba59..8a62f40e 100644 --- a/packages/util/logger.ts +++ b/packages/util/logger.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import { pino } from 'pino'; import { LOG_LEVEL, SessionLogger, type SessionLoggerOptions } from '@session/logger'; import { isProduction } from './env'; diff --git a/packages/util/tests/env.spec.ts b/packages/util/tests/env.spec.ts index c938377f..e655d48d 100644 --- a/packages/util/tests/env.spec.ts +++ b/packages/util/tests/env.spec.ts @@ -16,7 +16,7 @@ describe('getEnvironment', () => { const result = getEnvironment(); expect(result).toBe(Environment.PRD); expect(mockConsoleWarn).not.toHaveBeenCalled(); - expect(mockConsoleLog).toHaveBeenCalled(); + expect(mockConsoleLog).not.toHaveBeenCalled(); }); test('getEnvironment should return the default environment (DEV) when an invalid environment flag is provided', () => { @@ -24,7 +24,7 @@ describe('getEnvironment', () => { const result = getEnvironment(); expect(result).toBe(Environment.DEV); expect(mockConsoleWarn).toHaveBeenCalled(); - expect(mockConsoleLog).toHaveBeenCalled(); + expect(mockConsoleLog).not.toHaveBeenCalled(); }); test('getEnvironment should return the default environment (DEV) when no environment flag is provided', () => { @@ -33,7 +33,7 @@ describe('getEnvironment', () => { const result = getEnvironment(); expect(result).toBe(Environment.DEV); expect(mockConsoleWarn).toHaveBeenCalled(); - expect(mockConsoleLog).toHaveBeenCalled(); + expect(mockConsoleLog).not.toHaveBeenCalled(); }); }); @@ -45,7 +45,7 @@ describe('isProduction', () => { process.env.NEXT_PUBLIC_ENV_FLAG = Environment.PRD; const result = isProduction(); expect(result).toBe(true); - expect(mockConsoleLog).toHaveBeenCalled(); + expect(mockConsoleLog).not.toHaveBeenCalled(); }); test('isProduction should return false when the environment is not PRD', () => { diff --git a/packages/wallet/README.md b/packages/wallet/README.md new file mode 100644 index 00000000..ac44fc18 --- /dev/null +++ b/packages/wallet/README.md @@ -0,0 +1,7 @@ +# @session/wallet + +This package is a wallet library for interacting with the Session Token. + +## Getting Started + +You can follow the generic instructions in the root [README.md](../../README.md#getting-started) to get started. diff --git a/packages/wallet/components/WalletAddTokenButton.tsx b/packages/wallet/components/WalletAddTokenButton.tsx index 7391e3c6..c5966d0f 100644 --- a/packages/wallet/components/WalletAddTokenButton.tsx +++ b/packages/wallet/components/WalletAddTokenButton.tsx @@ -4,7 +4,7 @@ import { useWeb3Modal } from '@web3modal/wagmi/react'; import { useMemo } from 'react'; import { useAddSessionTokenToWallet, useWalletChain } from '../hooks/wallet-hooks'; import { ButtonDataTestId } from '../testing/data-test-ids'; -import { toast } from '@session/ui/lib/sonner'; +import { toast } from '@session/ui/lib/toast'; export type WalletAddTokenButtonProps = ButtonVariantProps & { tokenIcon: string; @@ -20,7 +20,7 @@ export type WalletAddTokenButtonProps = ButtonVariantProps & { }; errors: { fail: string; - } + }; }; export default function WalletAddTokenButton(props: WalletAddTokenButtonProps) { @@ -40,7 +40,7 @@ export default function WalletAddTokenButton(props: WalletAddTokenButtonProps) { if (error) { console.error(error); - toast.error(props.errors.fail) + toast.error(props.errors.fail); } return ( diff --git a/packages/wallet/components/WalletButton.tsx b/packages/wallet/components/WalletButton.tsx index ae3e79cb..38f5c3ce 100644 --- a/packages/wallet/components/WalletButton.tsx +++ b/packages/wallet/components/WalletButton.tsx @@ -1,13 +1,13 @@ -import { SENT_DECIMALS, SENT_SYMBOL } from '@session/contracts'; +import { SENT_SYMBOL } from '@session/contracts'; import { Button } from '@session/ui/components/ui/button'; import { SessionTokenIcon } from '@session/ui/icons/SessionTokenIcon'; import { cn } from '@session/ui/lib/utils'; -import { formatBigIntTokenValue } from '@session/util/maths'; import { collapseString } from '@session/util/string'; import { useMemo } from 'react'; import { ButtonDataTestId } from '../testing/data-test-ids'; import { ConnectedWalletAvatar } from './WalletAvatar'; import { WalletButtonProps } from './WalletModalButton'; +import { formatSENTBigInt } from '@session/contracts/hooks/SENT'; export function WalletButton({ labels, @@ -31,6 +31,11 @@ export function WalletButton({ [ensName, arbName, address, fallbackName] ); + const formattedBalance = useMemo( + () => (tokenBalance ? formatSENTBigInt(tokenBalance) : `0 ${SENT_SYMBOL}`), + [tokenBalance] + ); + return (
) : null}
= 16'} + '@noble/hashes@1.5.0': + resolution: {integrity: sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2716,8 +2723,8 @@ packages: resolution: {integrity: sha512-7nakIjcRSs6781LkizYpIfXh1DYlkUDqyALciqz/BjFU/S97sVjZdL4cuKsG9NEarytE+f6p0Qbq2Bo1aocVUA==} engines: {node: '>=16'} - '@scure/base@1.1.6': - resolution: {integrity: sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==} + '@scure/base@1.1.8': + resolution: {integrity: sha512-6CyAclxj3Nb0XT7GHK6K4zK6k2xJm6E4Ft0Ohjt4WgegiFUHEtFb2CGzmPmGBwoIhrLsqNLYfLr04Y1GePrzZg==} '@scure/bip32@1.3.3': resolution: {integrity: sha512-LJaN3HwRbfQK0X1xFSi0Q9amqOgzQnnDngIt+ZlsBC3Bm7/nE7K0kwshZHyaru79yIVRv/e1mQAjZyuZG6jOFQ==} @@ -4783,6 +4790,20 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + framer-motion@11.5.4: + resolution: {integrity: sha512-E+tb3/G6SO69POkdJT+3EpdMuhmtCh9EWuK4I1DnIC23L7tFPrl8vxP+LSovwaw6uUr73rUbpb4FgK011wbRJQ==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -9703,8 +9724,8 @@ snapshots: '@metamask/utils@8.4.0': dependencies: '@ethereumjs/tx': 4.2.0 - '@noble/hashes': 1.4.0 - '@scure/base': 1.1.6 + '@noble/hashes': 1.5.0 + '@scure/base': 1.1.8 '@types/debug': 4.1.12 debug: 4.3.4 pony-cause: 2.1.11 @@ -9817,6 +9838,8 @@ snapshots: '@noble/hashes@1.4.0': {} + '@noble/hashes@1.5.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -10820,29 +10843,29 @@ snapshots: '@safe-global/safe-gateway-typescript-sdk@3.21.1': {} - '@scure/base@1.1.6': {} + '@scure/base@1.1.8': {} '@scure/bip32@1.3.3': dependencies: '@noble/curves': 1.3.0 '@noble/hashes': 1.3.3 - '@scure/base': 1.1.6 + '@scure/base': 1.1.8 '@scure/bip32@1.4.0': dependencies: '@noble/curves': 1.4.0 '@noble/hashes': 1.4.0 - '@scure/base': 1.1.6 + '@scure/base': 1.1.8 '@scure/bip39@1.2.2': dependencies: '@noble/hashes': 1.3.3 - '@scure/base': 1.1.6 + '@scure/base': 1.1.8 '@scure/bip39@1.3.0': dependencies: '@noble/hashes': 1.4.0 - '@scure/base': 1.1.6 + '@scure/base': 1.1.8 '@sideway/address@4.1.5': dependencies: @@ -12112,8 +12135,8 @@ snapshots: lit: 3.1.0 qrcode: 1.5.3 - ? '@web3modal/wagmi@4.2.3(@react-native-async-storage/async-storage@1.23.1(react-native@0.74.1(@babel/core@7.24.6)(@babel/preset-env@7.24.5(@babel/core@7.24.6))(@types/react@18.3.1)(bufferutil@4.0.8)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.1)(@wagmi/connectors@5.1.8(@react-native-async-storage/async-storage@1.23.1(react-native@0.74.1(@babel/core@7.24.6)(@babel/preset-env@7.24.5(@babel/core@7.24.6))(@types/react@18.3.1)(bufferutil@4.0.8)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.1)(@wagmi/core@2.13.4(@tanstack/query-core@5.51.1)(@types/react@18.3.1)(react@18.3.1)(typescript@5.4.5)(viem@2.21.1(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(react-dom@18.3.1(react@18.3.1))(react-native@0.74.1(@babel/core@7.24.6)(@babel/preset-env@7.24.5(@babel/core@7.24.6))(@types/react@18.3.1)(bufferutil@4.0.8)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.4.5)(utf-8-validate@5.0.10)(viem@2.21.1(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8))(@wagmi/core@2.13.4(@tanstack/query-core@5.51.1)(@types/react@18.3.1)(react@18.3.1)(typescript@5.4.5)(viem@2.21.1(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(utf-8-validate@5.0.10)(viem@2.21.1(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8))' - : dependencies: + '@web3modal/wagmi@4.2.3(6xih3thcjuz6qgw7ybgdmzw6yy)': + dependencies: '@wagmi/connectors': 5.1.8(@react-native-async-storage/async-storage@1.23.1(react-native@0.74.1(@babel/core@7.24.6)(@babel/preset-env@7.24.5(@babel/core@7.24.6))(@types/react@18.3.1)(bufferutil@4.0.8)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.1)(@wagmi/core@2.13.4(@tanstack/query-core@5.51.1)(@types/react@18.3.1)(react@18.3.1)(typescript@5.4.5)(viem@2.21.1(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(react-dom@18.3.1(react@18.3.1))(react-native@0.74.1(@babel/core@7.24.6)(@babel/preset-env@7.24.5(@babel/core@7.24.6))(@types/react@18.3.1)(bufferutil@4.0.8)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.4.5)(utf-8-validate@5.0.10)(viem@2.21.1(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8) '@wagmi/core': 2.13.4(@tanstack/query-core@5.51.1)(@types/react@18.3.1)(react@18.3.1)(typescript@5.4.5)(viem@2.21.1(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8)) '@walletconnect/ethereum-provider': 2.13.0(@react-native-async-storage/async-storage@1.23.1(react-native@0.74.1(@babel/core@7.24.6)(@babel/preset-env@7.24.5(@babel/core@7.24.6))(@types/react@18.3.1)(bufferutil@4.0.8)(react@18.3.1)(utf-8-validate@5.0.10)))(@types/react@18.3.1)(bufferutil@4.0.8)(react@18.3.1)(utf-8-validate@5.0.10) @@ -13407,7 +13430,7 @@ snapshots: debug: 4.3.4 enhanced-resolve: 5.16.0 eslint: 8.57.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.8.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 @@ -13419,12 +13442,13 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.4.5) eslint: 8.57.0 + eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0) transitivePeerDependencies: - supports-color @@ -13860,6 +13884,13 @@ snapshots: fraction.js@4.3.7: {} + framer-motion@11.5.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + tslib: 2.6.2 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + fresh@0.5.2: {} fs-constants@1.0.0: {} @@ -17306,7 +17337,7 @@ snapshots: webauthn-p256@0.0.5: dependencies: '@noble/curves': 1.4.0 - '@noble/hashes': 1.4.0 + '@noble/hashes': 1.5.0 webextension-polyfill@0.10.0: {} diff --git a/turbo.json b/turbo.json index f9331a01..1cafac34 100644 --- a/turbo.json +++ b/turbo.json @@ -49,6 +49,17 @@ "^lint" ] }, - "test": {} + "check-telemetry": { + "cache": false + }, + "test": {}, + "gh": { + "dependsOn": [ + "^check-types", + "^lint", + "^test", + "^build" + ] + } } }