diff --git a/.github/workflows/ci.subgraph-basin.yaml b/.github/workflows/ci.subgraph-basin.yaml new file mode 100644 index 0000000000..6e2db3440e --- /dev/null +++ b/.github/workflows/ci.subgraph-basin.yaml @@ -0,0 +1,78 @@ +name: Subgraph Basin + +on: + pull_request: + types: [opened, synchronize] + paths: + - "projects/subgraph-basin/**" + - "projects/subgraph-core/**" + +jobs: + compile: + runs-on: ubuntu-latest + name: Compile + steps: + - name: Check out source repository + uses: actions/checkout@v3 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: "20" + - name: Cache Node Modules + id: node-modules-cache + uses: actions/cache@v3 + with: + path: "**/node_modules" + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + - name: Install The Graph CLI + run: npm install -g @graphprotocol/graph-cli + - name: Install Dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: yarn install --immutable + working-directory: projects/subgraph-basin + + # Generate code and check for uncommitted changes + # https://github.com/marketplace/actions/check-uncommitted-changes + - name: Generate Subgraph Code + run: yarn codegen + working-directory: projects/subgraph-basin + - name: Check for uncommitted changes + id: check-changes + uses: mskri/check-uncommitted-changes-action@v1.0.1 + - name: Evaluate if there are changes + if: steps.check-changes.outputs.outcome == failure() + run: echo "There are uncommitted changes - execute 'yarn codegen' locally and commit the generated files!" + + - name: Build Subgraph + run: yarn build + working-directory: projects/subgraph-basin + test: + runs-on: ubuntu-latest + name: Test + steps: + - name: Check out source repository + uses: actions/checkout@v3 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: "20" + - name: Cache Node Modules + id: node-modules-cache + uses: actions/cache@v3 + with: + path: "**/node_modules" + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + - name: Install The Graph CLI + run: npm install -g @graphprotocol/graph-cli + - name: Install Dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: yarn install --immutable + working-directory: projects/subgraph-basin + + - name: Generate Subgraph Code + run: yarn codegen + working-directory: projects/subgraph-basin + + - name: Run Tests + run: yarn test + working-directory: projects/subgraph-basin diff --git a/.github/workflows/ci.subgraph-bean.yaml b/.github/workflows/ci.subgraph-bean.yaml index 682f06ccf4..c0ab78945d 100644 --- a/.github/workflows/ci.subgraph-bean.yaml +++ b/.github/workflows/ci.subgraph-bean.yaml @@ -5,6 +5,7 @@ on: types: [opened, synchronize] paths: - "projects/subgraph-bean/**" + - "projects/subgraph-core/**" jobs: compile: @@ -16,7 +17,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v3 with: - node-version: "18" + node-version: "20" - name: Cache Node Modules id: node-modules-cache uses: actions/cache@v3 @@ -28,6 +29,7 @@ jobs: - name: Install Dependencies if: steps.node-modules-cache.outputs.cache-hit != 'true' run: yarn install --immutable + working-directory: projects/subgraph-bean # Generate code and check for uncommitted changes # https://github.com/marketplace/actions/check-uncommitted-changes @@ -44,7 +46,33 @@ jobs: - name: Build Subgraph run: yarn build working-directory: projects/subgraph-bean - # TODO: add matchstick test suite - #- name: Run Tests - # run: yarn test - # working-directory: "${{ matrix.value }}" + test: + runs-on: ubuntu-latest + name: Test + steps: + - name: Check out source repository + uses: actions/checkout@v3 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: "20" + - name: Cache Node Modules + id: node-modules-cache + uses: actions/cache@v3 + with: + path: "**/node_modules" + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + - name: Install The Graph CLI + run: npm install -g @graphprotocol/graph-cli + - name: Install Dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: yarn install --immutable + working-directory: projects/subgraph-bean + + - name: Generate Subgraph Code + run: yarn codegen + working-directory: projects/subgraph-bean + + - name: Run Tests + run: yarn test + working-directory: projects/subgraph-bean diff --git a/.github/workflows/ci.subgraph-beanft.yaml b/.github/workflows/ci.subgraph-beanft.yaml new file mode 100644 index 0000000000..e7de52fe4d --- /dev/null +++ b/.github/workflows/ci.subgraph-beanft.yaml @@ -0,0 +1,78 @@ +name: Subgraph BeaNFT + +on: + pull_request: + types: [opened, synchronize] + paths: + - "projects/subgraph-beanft/**" + - "projects/subgraph-core/**" + +jobs: + compile: + runs-on: ubuntu-latest + name: Compile + steps: + - name: Check out source repository + uses: actions/checkout@v3 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: "20" + - name: Cache Node Modules + id: node-modules-cache + uses: actions/cache@v3 + with: + path: "**/node_modules" + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + - name: Install The Graph CLI + run: npm install -g @graphprotocol/graph-cli + - name: Install Dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: yarn install --immutable + working-directory: projects/subgraph-beanft + + # Generate code and check for uncommitted changes + # https://github.com/marketplace/actions/check-uncommitted-changes + - name: Generate Subgraph Code + run: yarn codegen + working-directory: projects/subgraph-beanft + - name: Check for uncommitted changes + id: check-changes + uses: mskri/check-uncommitted-changes-action@v1.0.1 + - name: Evaluate if there are changes + if: steps.check-changes.outputs.outcome == failure() + run: echo "There are uncommitted changes - execute 'yarn codegen' locally and commit the generated files!" + + - name: Build Subgraph + run: yarn build + working-directory: projects/subgraph-beanft + test: + runs-on: ubuntu-latest + name: Test + steps: + - name: Check out source repository + uses: actions/checkout@v3 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: "20" + - name: Cache Node Modules + id: node-modules-cache + uses: actions/cache@v3 + with: + path: "**/node_modules" + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + - name: Install The Graph CLI + run: npm install -g @graphprotocol/graph-cli + - name: Install Dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: yarn install --immutable + working-directory: projects/subgraph-beanft + + - name: Generate Subgraph Code + run: yarn codegen + working-directory: projects/subgraph-beanft + + - name: Run Tests + run: yarn test + working-directory: projects/subgraph-beanft diff --git a/.github/workflows/ci.subgraph-beanstalk.yaml b/.github/workflows/ci.subgraph-beanstalk.yaml index 00d96e47b5..8f55b30fc3 100644 --- a/.github/workflows/ci.subgraph-beanstalk.yaml +++ b/.github/workflows/ci.subgraph-beanstalk.yaml @@ -5,6 +5,7 @@ on: types: [opened, synchronize] paths: - "projects/subgraph-beanstalk/**" + - "projects/subgraph-core/**" jobs: compile: @@ -16,7 +17,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v3 with: - node-version: "18" + node-version: "20" - name: Cache Node Modules id: node-modules-cache uses: actions/cache@v3 @@ -28,6 +29,7 @@ jobs: - name: Install Dependencies if: steps.node-modules-cache.outputs.cache-hit != 'true' run: yarn install --immutable + working-directory: projects/subgraph-beanstalk # Generate code and check for uncommitted changes # https://github.com/marketplace/actions/check-uncommitted-changes @@ -44,7 +46,33 @@ jobs: - name: Build Subgraph run: yarn build working-directory: projects/subgraph-beanstalk - # TODO: add matchstick test suite - #- name: Run Tests - # run: yarn test - # working-directory: "${{ matrix.value }}" + test: + runs-on: ubuntu-latest + name: Test + steps: + - name: Check out source repository + uses: actions/checkout@v3 + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: "20" + - name: Cache Node Modules + id: node-modules-cache + uses: actions/cache@v3 + with: + path: "**/node_modules" + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + - name: Install The Graph CLI + run: npm install -g @graphprotocol/graph-cli + - name: Install Dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: yarn install --immutable + working-directory: projects/subgraph-beanstalk + + - name: Generate Subgraph Code + run: yarn codegen + working-directory: projects/subgraph-beanstalk + + - name: Run Tests + run: yarn test + working-directory: projects/subgraph-beanstalk diff --git a/.github/workflows/deploy.subgraph.yaml b/.github/workflows/deploy.subgraph.yaml new file mode 100644 index 0000000000..6583a64381 --- /dev/null +++ b/.github/workflows/deploy.subgraph.yaml @@ -0,0 +1,48 @@ +name: Deploy Subgraph + +on: + workflow_dispatch: + inputs: + environment: + description: "Deployment environment (prod/dev/testing)" + required: true + subgraph: + description: "Subgraph name (beanstalk/bean/basin/beanft)" + required: true + branch: + description: "Branch name" + required: true + +jobs: + validation: + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.check_env.outputs.environment }} + steps: + - name: Validate environment input + id: check_env + run: | + # Check if the environment is prod/dev/testing/prev + if [[ "${{ github.event.inputs.environment }}" != "prod" && "${{ github.event.inputs.environment }}" != "dev" && "${{ github.event.inputs.environment }}" != "testing" && "${{ github.event.inputs.environment }}" != "prev" ]]; then + echo "Error: Environment must be one of 'prod', 'dev', 'testing'." + exit 1 + fi + # Check if the subgraph is a valid selection + if [[ "${{ github.event.inputs.subgraph }}" != "beanstalk" && "${{ github.event.inputs.subgraph }}" != "bean" && "${{ github.event.inputs.subgraph }}" != "basin" && "${{ github.event.inputs.subgraph }}" != "beanft" ]]; then + echo "Error: Subgraph must be one of 'beanstalk', 'bean', 'basin', 'beanft'." + exit 1 + fi + + deploy: + needs: validation + runs-on: ubuntu-latest + steps: + - name: Install SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.GRAPH_SERVER_SSH_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H ${{ secrets.GRAPH_SERVER_HOST }} >> ~/.ssh/known_hosts + + - name: Execute Remote Deployment Script + run: ssh -i ~/.ssh/id_ed25519 github@${{ secrets.GRAPH_SERVER_HOST }} "bash /home/github/deploy.sh ${{ github.event.inputs.branch }} ${{ github.event.inputs.subgraph }} ${{ github.event.inputs.environment }}" diff --git a/.prettierrc b/.prettierrc index 80645c6ebc..43834780bb 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,5 +2,13 @@ "printWidth": 100, "singleQuote": false, "semi": true, - "trailingComma": "none" + "trailingComma": "none", + "overrides": [ + { + "files": "projects/subgraph-*/**", + "options": { + "printWidth": 140 + } + } + ] } diff --git a/PROPOSALS.md b/PROPOSALS.md index 9ac5cacb22..738e545652 100644 --- a/PROPOSALS.md +++ b/PROPOSALS.md @@ -50,6 +50,12 @@ You can read more about BIPs [here](https://docs.bean.money/almanac/governance/p * [BIP-39](https://bean.money/bip-39): Beanstalk Farms 2024 Development Budget * [BIP-40](https://bean.money/bip-40): Beanstalk Farms 2024 Development Budget * [BIP-41](https://bean.money/bip-41): Immunefi Program Update +* [BIP-42](https://bean.money/bip-42): Seed Gauge System +* [BIP-43](https://bean.money/bip-43): Hypernative +* [BIP-44](https://bean.money/bip-44): Seed Gauge System +* [BIP-45](https://bean.money/bip-45): Seed Gauge System +* [BIP-46](https://bean.money/bip-46): Hypernative +* [BIP-47](https://bean.money/bip-47): Adjust Quorum ## Emergency Beanstalk Improvement Proposal (EBIP) @@ -72,6 +78,8 @@ You can read about the BCM's Emergency Response Procedures [here](https://docs.b * [EBIP-12](https://bean.money/ebip-12): Remove Convert * [EBIP-13](https://bean.money/ebip-13): Re-Add Convert * [EBIP-14](https://bean.money/ebip-14): Remove Vesting Period +* [EBIP-15](https://bean.money/ebip-15): Seed Gauge System Fixes +* [EBIP-16](https://bean.money/ebip-16): Fix Germinating Earned Bean Deposits ## Beanstalk Operations Proposal (BOP) diff --git a/projects/cli/src/commands/setbalance.ts b/projects/cli/src/commands/setbalance.ts index aa60c95bca..40fcc83206 100644 --- a/projects/cli/src/commands/setbalance.ts +++ b/projects/cli/src/commands/setbalance.ts @@ -11,7 +11,7 @@ export const setbalance = async (sdk, chain, { account, symbol, amount }) => { if (!symbol) { await chain.setAllBalances(account, amount); } else { - const symbols = ["ETH", "WETH", "BEAN", "USDT", "USDC", "DAI", "3CRV", "BEAN3CRV", "BEANWETH", "urBEAN", "urBEANWETH", "ROOT"]; + const symbols = ["ETH", "WETH", "BEAN", "USDT", "USDC", "DAI", "CRV3", "BEAN3CRV", "BEANWETH", "urBEAN", "urBEANWETH", "ROOT"]; if (!symbols.includes(symbol)) { console.log(`${chalk.bold.red("Error")} - ${chalk.bold.white(symbol)} is not a valid token. Valid options are: `); console.log(symbols.map((s) => chalk.green(s)).join(", ")); diff --git a/projects/dex-ui/graphql.schema.json b/projects/dex-ui/graphql.schema.json index 42810b79ee..f0ab01b562 100644 --- a/projects/dex-ui/graphql.schema.json +++ b/projects/dex-ui/graphql.schema.json @@ -13712,7 +13712,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD", + "name": "deltaTradeVolumeUSD", "description": " All historical trade volume occurred in this well, in USD ", "args": [], "type": { @@ -15272,7 +15272,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD", + "name": "deltaTradeVolumeUSD", "description": null, "type": { "kind": "SCALAR", @@ -15284,7 +15284,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD_gt", + "name": "deltaTradeVolumeUSD_gt", "description": null, "type": { "kind": "SCALAR", @@ -15296,7 +15296,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD_gte", + "name": "deltaTradeVolumeUSD_gte", "description": null, "type": { "kind": "SCALAR", @@ -15308,7 +15308,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD_in", + "name": "deltaTradeVolumeUSD_in", "description": null, "type": { "kind": "LIST", @@ -15328,7 +15328,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD_lt", + "name": "deltaTradeVolumeUSD_lt", "description": null, "type": { "kind": "SCALAR", @@ -15340,7 +15340,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD_lte", + "name": "deltaTradeVolumeUSD_lte", "description": null, "type": { "kind": "SCALAR", @@ -15352,7 +15352,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD_not", + "name": "deltaTradeVolumeUSD_not", "description": null, "type": { "kind": "SCALAR", @@ -15364,7 +15364,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD_not_in", + "name": "deltaTradeVolumeUSD_not_in", "description": null, "type": { "kind": "LIST", @@ -16561,7 +16561,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD", + "name": "deltaTradeVolumeUSD", "description": null, "isDeprecated": false, "deprecationReason": null @@ -17840,7 +17840,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD", + "name": "deltaTradeVolumeUSD", "description": " All historical trade volume occurred in this well, in USD ", "args": [], "type": { @@ -19304,7 +19304,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD", + "name": "deltaTradeVolumeUSD", "description": null, "type": { "kind": "SCALAR", @@ -19316,7 +19316,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD_gt", + "name": "deltaTradeVolumeUSD_gt", "description": null, "type": { "kind": "SCALAR", @@ -19328,7 +19328,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD_gte", + "name": "deltaTradeVolumeUSD_gte", "description": null, "type": { "kind": "SCALAR", @@ -19340,7 +19340,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD_in", + "name": "deltaTradeVolumeUSD_in", "description": null, "type": { "kind": "LIST", @@ -19360,7 +19360,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD_lt", + "name": "deltaTradeVolumeUSD_lt", "description": null, "type": { "kind": "SCALAR", @@ -19372,7 +19372,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD_lte", + "name": "deltaTradeVolumeUSD_lte", "description": null, "type": { "kind": "SCALAR", @@ -19384,7 +19384,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD_not", + "name": "deltaTradeVolumeUSD_not", "description": null, "type": { "kind": "SCALAR", @@ -19396,7 +19396,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD_not_in", + "name": "deltaTradeVolumeUSD_not_in", "description": null, "type": { "kind": "LIST", @@ -20699,7 +20699,7 @@ "deprecationReason": null }, { - "name": "deltaVolumeUSD", + "name": "deltaTradeVolumeUSD", "description": null, "isDeprecated": false, "deprecationReason": null diff --git a/projects/dex-ui/package.json b/projects/dex-ui/package.json index 8352a4a3c1..a01c41b755 100644 --- a/projects/dex-ui/package.json +++ b/projects/dex-ui/package.json @@ -21,17 +21,20 @@ }, "dependencies": { "@beanstalk/sdk": "workspace:*", + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-dropdown-menu": "2.1.1", "@tanstack/react-query": "5.28.4", "@tanstack/react-query-devtools": "5.28.4", "@typechain/ethers-v5": "10.2.1", + "alchemy-sdk": "3.3.1", "connectkit": "1.7.2", "ethers": "^5.7.2", "graphql-request": "5.2.0", "lightweight-charts": "4.1.3", - "loadash": "1.0.0", "prettier": "3.2.5", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "7.51.5", "react-hot-toast": "2.4.1", "react-jazzicon": "1.0.4", "react-router-dom": "^6.22.1", diff --git a/projects/dex-ui/public/basin.pdf b/projects/dex-ui/public/basin.pdf index 0a2baf491e..baea7225b1 100644 Binary files a/projects/dex-ui/public/basin.pdf and b/projects/dex-ui/public/basin.pdf differ diff --git a/projects/dex-ui/public/multi-flow-pump.pdf b/projects/dex-ui/public/multi-flow-pump.pdf index 633f5e9650..531af83b49 100644 Binary files a/projects/dex-ui/public/multi-flow-pump.pdf and b/projects/dex-ui/public/multi-flow-pump.pdf differ diff --git a/projects/dex-ui/src/assets/images/beanstalk-farms.png b/projects/dex-ui/src/assets/images/beanstalk-farms.png new file mode 100644 index 0000000000..66ea4b8f68 Binary files /dev/null and b/projects/dex-ui/src/assets/images/beanstalk-farms.png differ diff --git a/projects/dex-ui/src/assets/images/brendan-twitter-pfp.png b/projects/dex-ui/src/assets/images/brendan-twitter-pfp.png new file mode 100644 index 0000000000..fbfa5e3469 Binary files /dev/null and b/projects/dex-ui/src/assets/images/brendan-twitter-pfp.png differ diff --git a/projects/dex-ui/src/assets/images/clock-icon.svg b/projects/dex-ui/src/assets/images/clock-icon.svg new file mode 100644 index 0000000000..a8916bf6e0 --- /dev/null +++ b/projects/dex-ui/src/assets/images/clock-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/projects/dex-ui/src/assets/images/code4rena-logo.png b/projects/dex-ui/src/assets/images/code4rena-logo.png new file mode 100644 index 0000000000..47938097dd Binary files /dev/null and b/projects/dex-ui/src/assets/images/code4rena-logo.png differ diff --git a/projects/dex-ui/src/assets/images/cyrfin-logo.svg b/projects/dex-ui/src/assets/images/cyrfin-logo.svg new file mode 100644 index 0000000000..b317408252 --- /dev/null +++ b/projects/dex-ui/src/assets/images/cyrfin-logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/projects/dex-ui/src/assets/images/halborn-logo.png b/projects/dex-ui/src/assets/images/halborn-logo.png new file mode 100644 index 0000000000..1d6ff52cb5 Binary files /dev/null and b/projects/dex-ui/src/assets/images/halborn-logo.png differ diff --git a/projects/dex-ui/src/assets/images/tokens/wstETH.svg b/projects/dex-ui/src/assets/images/tokens/wstETH.svg new file mode 100644 index 0000000000..bf444dfe02 --- /dev/null +++ b/projects/dex-ui/src/assets/images/tokens/wstETH.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/projects/dex-ui/src/breakpoints.ts b/projects/dex-ui/src/breakpoints.ts index d47f2c786c..e115fed785 100644 --- a/projects/dex-ui/src/breakpoints.ts +++ b/projects/dex-ui/src/breakpoints.ts @@ -1,6 +1,7 @@ export const size = { mobile: "769px", - tablet: "1024px" + tablet: "1024px", + desktop: "1200px", }; const mediaSizes = { diff --git a/projects/dex-ui/src/components/App/App.tsx b/projects/dex-ui/src/components/App/App.tsx index 9e867026c6..cacac60c7c 100644 --- a/projects/dex-ui/src/components/App/App.tsx +++ b/projects/dex-ui/src/components/App/App.tsx @@ -10,6 +10,7 @@ import { Build } from "src/pages/Build"; import { Swap } from "src/pages/Swap"; import { Settings } from "src/settings"; import { Liquidity } from "src/pages/Liquidity"; +import { Create } from "src/pages/Create"; export const App = ({}) => { const isNotProd = !Settings.PRODUCTION; @@ -21,8 +22,9 @@ export const App = ({}) => { } /> } /> } /> - } /> } /> + } /> + } /> {isNotProd && } />} } /> diff --git a/projects/dex-ui/src/components/BottomDrawer.tsx b/projects/dex-ui/src/components/BottomDrawer.tsx index 7105b01e6a..09d4629fcf 100644 --- a/projects/dex-ui/src/components/BottomDrawer.tsx +++ b/projects/dex-ui/src/components/BottomDrawer.tsx @@ -61,7 +61,8 @@ const Container = styled.div` display: flex; flex-direction: column; position: fixed; - width: 100vw; + width: 100svw; + max-height: 80svh; left: 0; transition: all 0.3s ease-in-out; bottom: ${({ showDrawer }) => (showDrawer ? "0" : "-100%")}; diff --git a/projects/dex-ui/src/components/Button.tsx b/projects/dex-ui/src/components/Button.tsx new file mode 100644 index 0000000000..2a1df8b064 --- /dev/null +++ b/projects/dex-ui/src/components/Button.tsx @@ -0,0 +1,98 @@ +import React, { ButtonHTMLAttributes, CSSProperties, forwardRef } from "react"; +import { + CommonCssProps, + CommonCssStyles, + FlexPropertiesStyle, + FlexPropertiesProps, + makeCssStyle +} from "src/utils/ui/styled"; +import { theme } from "src/utils/ui/theme"; +import styled from "styled-components"; +import { Spinner } from "./Spinner"; + +export type ButtonVariant = "outlined" | "contained"; // | "text" (Add Text Variant later) +export type ButtonColor = "primary" | "secondary"; + +type BaseButtonProps = { + $variant?: ButtonVariant; + disabled?: boolean; + $loading?: boolean; + $fullWidth?: boolean; + $primary?: boolean; + $secondary?: boolean; +}; + +type CommonButtonStyles = { + $whiteSpace?: CSSProperties["whiteSpace"]; +}; + +export type ButtonProps = ButtonHTMLAttributes & + BaseButtonProps & + CommonCssProps & + FlexPropertiesProps & + CommonButtonStyles; + +export const ButtonPrimary = forwardRef( + ({ children, $loading, ...props }: ButtonProps, ref) => { + return ( + + {$loading ? : children} + + ); + } +); + +const getButtonFontColor = (props: BaseButtonProps) => { + if (props.$variant === "outlined") { + if (props.disabled || props.$loading) return theme.colors.disabled; + return props.$secondary ? theme.colors.stoneLight : theme.colors.black; + } + + return props.$secondary ? theme.colors.black : theme.colors.white; +}; + +const getButtonBgColor = (props: BaseButtonProps) => { + if (props.$variant === "outlined") return theme.colors.white; + if (props.disabled || props.$loading) return theme.colors.disabled; + return props.$secondary ? theme.colors.stoneLight : theme.colors.black; +}; + +const getButtonOutline = (props: BaseButtonProps) => { + if (props.disabled) return theme.colors.disabled; + if (props.$variant === "outlined") { + return props.$secondary ? theme.colors.lightGray : theme.colors.black; + } + return theme.colors.black; +}; + +const ButtonBase = styled.button` + display: flex; + justify-content: center; + align-items: center; + padding: ${theme.spacing(1.5)}; + box-sizing: border-box; + + border: none; + background: ${getButtonBgColor}; + outline: 0.5px solid ${getButtonOutline}; + outline-offset: -0.5px; + color: ${getButtonFontColor}; + ${(p) => makeCssStyle(p, "$whiteSpace")} + + ${CommonCssStyles} + ${FlexPropertiesStyle} + ${({ $fullWidth }) => $fullWidth && "width: 100%;"} + + ${theme.font.styles.variant("button-link")} + cursor: ${(props) => (props.disabled ? "default" : "pointer")}; + + &:hover, + &:focus { + outline: ${(props) => (!props.disabled ? `2px solid ${theme.colors.primary}` : "")}; + } + + ${theme.media.query.sm.only} { + padding: ${theme.spacing(1)}; + ${theme.font.styles.variant("xs")} + } +`; diff --git a/projects/dex-ui/src/components/Create/ComponentLibraryTable.tsx b/projects/dex-ui/src/components/Create/ComponentLibraryTable.tsx new file mode 100644 index 0000000000..a442972848 --- /dev/null +++ b/projects/dex-ui/src/components/Create/ComponentLibraryTable.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import styled from "styled-components"; + +import { Table, Td, THead, ResponsiveTr, Th, TBody, Row } from "src/components//Table"; +import { Link } from "react-router-dom"; +import { theme } from "src/utils/ui/theme"; +import { Text } from "src/components/Typography"; +import { useWhitelistedWellComponents } from "./useWhitelistedWellComponents"; + +export const ComponentLibraryTable = () => { + const { + components: { wellImplementations, pumps, wellFunctions } + } = useWhitelistedWellComponents(); + + const entries = [...pumps, ...wellFunctions, ...wellImplementations]; + + const openInNewTab = (url: string | undefined) => { + if (!url) return; + window.open(url, "_blank", "noopener noreferrer"); + }; + + return ( + + + + Well Component + Type + + Developer + + + + + {entries.map(({ component, info }, i) => { + const deployInfo = info.find((data) => data.label === "Deployed By"); + if (!deployInfo || typeof deployInfo.value !== "string") return null; + + return ( + openInNewTab(component.url)}> + + {component.name} + {component.summary} + + + + {component.type.imgSrc && } + {component.type.display} + + + + + + {deployInfo.value} + + + + ); + })} + + + ); +}; + +/// Table +const StyledTable = styled(Table)` + overflow: auto; +`; + +const StyledTh = styled(Th)<{ $hideMobile?: boolean }>` + ${(props) => + props.$hideMobile && + ` + ${theme.media.query.sm.only} { + display: none; + } + `} +`; + +const StyledTd = styled(Td)<{ $hideMobile?: boolean }>` + padding: unset; + padding: ${theme.spacing(3, 2)}; + cursor: pointer; + ${(props) => + props.$hideMobile && + ` + ${theme.media.query.sm.only} { + display: none; + } + `} +`; + +const StyledTr = styled(Row)` + height: unset; + cursor: pointer; +`; + +const TextWrapper = styled.div` + display: inline-flex; + align-items: center; + gap: ${theme.spacing(1)}; + cursor: inherit; +`; + +const IconImg = styled.img<{ $rounded?: boolean }>` + max-height: 24px; + max-width: 24px; + ${(props) => (props.$rounded ? "border-radius: 50%;" : "")} + margin-bottom: ${theme.spacing(0.375)}; +`; + +const StyledLink = styled(Link).attrs({ + target: "_blank", + rel: "noopener noreferrer" +})` + text-decoration: none; + color: ${theme.colors.black}; + outline: none; +`; + +const TableData = ({ + children, + url, + align = "right", + hideMobile +}: { + children: React.ReactNode; + align?: "left" | "right"; + url?: string; + hideMobile?: boolean; +}) => { + if (url) { + return ( + e.stopPropagation()}> + {children} + + ); + } + + return ( + + {children} + + ); +}; diff --git a/projects/dex-ui/src/components/Create/CreateWellProvider.tsx b/projects/dex-ui/src/components/Create/CreateWellProvider.tsx new file mode 100644 index 0000000000..bc9b2a8a44 --- /dev/null +++ b/projects/dex-ui/src/components/Create/CreateWellProvider.tsx @@ -0,0 +1,320 @@ +import React, { createContext, useCallback, useMemo, useState } from "react"; +import { ERC20Token, TokenValue } from "@beanstalk/sdk-core"; +import { DeepRequired } from "react-hook-form"; +import useSdk from "src/utils/sdk/useSdk"; +import { Log } from "src/utils/logger"; +import { Pump, WellFunction } from "@beanstalk/sdk-wells"; +import { useAccount } from "wagmi"; +import { usePumps } from "src/wells/pump/usePumps"; +import BoreWellUtils from "src/wells/boreWell"; +import { clearWellsCache } from "src/wells/useWells"; +import { useQueryClient } from "@tanstack/react-query"; + +/** + * Architecture notes: @Space-Bean + * + * This create well flow consists of 4 pages. + * 1. Select the well implementation + * 2. Select the well function, tokens, and pump + * 3. Enter the well name and symbol + * 4. Enter the liquidity amounts, salt, and deploy. + * + * + * Well Functions: + * - Every well function is optionally deployed with a 'data' parameter. It is important to note that if we initialize a WellFunction object + * w/ the incorrect 'data' value, any calls to the well function will be nonsensical. We utilize this 'data' value to determine the + * LP token supply the user will recieve when they seed liquidity. + * - In the case where the user decides to use their own well function, since we cannot know the value of 'data', we rely on the user + * to accurately provide the 'data' value. + * - In the case where it is a well function that is already deployed & is in use via other wells, we can safely fetch this data via Well.well(). + * + * Pumps: + * - Pumps are similar to well functions in that they are optionally deployed with a 'data' parameter. + * + * Deploying: + * - The user can choose to deploy a well with or without liquidity. + * - We use pipeline to deploy the well. + * - If the user decides to deploy with liquidity, because we we must be able to detministically predict the well address to add subsequently add liquidity, + * we must provide a valid 'salt' value. Aquifer.sol only creates a copy of a well implementation at a deterministic address if the 'salt' value is greater than 0. + * + * Vulnerabilities: + * - If the user provides the wrong 'data' value for a well function or a pump, the well may not deploy, may never function properly, or this may result in loss of funds. + */ + +type GoNextParams = { + goNext?: boolean; +}; + +type WellTokensParams = { + token1: ERC20Token; + token2: ERC20Token; +}; + +type LiquidityAmounts = { + token1Amount: string; + token2Amount: string; +}; + +type WellDetails = { + name: string; + symbol: string; +}; + +export type CreateWellContext = { + step: number; + wellImplementation: string | undefined; + wellFunctionAddress: string | undefined; + wellFunctionData: string | undefined; + wellFunction: WellFunction | undefined; + pumpAddress: string | undefined; + pumpData: string | undefined; + wellDetails: Partial; + wellTokens: Partial; + liquidity: Partial; + salt: number | undefined; + loading: boolean; + goBack: () => void; + goNext: () => void; + setStep1: (params: Partial<{ wellImplementation: string } & GoNextParams>) => void; + setStep2: ( + params: Partial< + { + wellFunctionAddress: string; + wellFunctionData: string; + token1: ERC20Token; + token2: ERC20Token; + pumpAddress: string; + pumpData: string; + wellFunction: WellFunction; + } & GoNextParams + > & {} + ) => void; + setStep3: (params: Partial) => void; + setStep4: (params: Partial) => void; + deployWell: ( + saltValue: number, + liquidity?: { + token1Amount: TokenValue; + token2Amount: TokenValue; + } + ) => Promise<{ wellAddress: string } | Error>; +}; + +export type CreateWellStepProps = DeepRequired<{ + step1: { + wellImplementation: CreateWellContext["wellImplementation"]; + }; + step2: { + wellFunctionAddress: CreateWellContext["wellFunctionAddress"]; + pumpAddress: CreateWellContext["pumpAddress"]; + wellTokens: CreateWellContext["wellTokens"]; + pumpData: CreateWellContext["pumpData"]; + wellFunctionData: CreateWellContext["wellFunctionData"]; + }; + step3: CreateWellContext["wellDetails"]; + step4: CreateWellContext["liquidity"] & { + salt: CreateWellContext["salt"]; + }; +}>; + +const Context = createContext(null); + +export const CreateWellProvider = ({ children }: { children: React.ReactNode }) => { + const { address: walletAddress } = useAccount(); + const sdk = useSdk(); + const pumps = usePumps(); + const queryClient = useQueryClient(); + + /// ----- Local State ----- + const [deploying, setDeploying] = useState(false); + const [step, setStep] = useState(0); + + // step 1 + const [wellImplementation, setWellImplementation] = useState(); + + // step 2 + const [pumpAddress, setPumpAddress] = useState(); + const [pumpData, setPumpData] = useState(); + const [wellFunction, setWellFunction] = useState(); + const [wellFunctionAddress, setWellFunctionAddress] = useState(); + const [wellFunctionData, setWellFunctionData] = useState(); + const [wellTokens, setWellTokens] = useState>({}); + + // step 3 + const [wellDetails, setWellDetails] = useState>({}); + + // step 4 + const [liquidity, setLiquidity] = useState>({}); + const [salt, setDeploySalt] = useState(); + + /// ------- State Methods ----- + const methods = useMemo(() => { + const handleSetLiquidity = (params: LiquidityAmounts) => setLiquidity(params); + const handleSetSalt = (_salt: number) => setDeploySalt(_salt); + const handleGoNext = () => { + setStep((_step) => Math.min(_step + 1, 3)); + }; + const handleGoBack = () => { + setStep((_step) => Math.max(_step - 1, 0)); + }; + const handleSetPump = (pump: string) => setPumpAddress(pump); + const handleSetWellFunction = (wellFunction: string) => setWellFunctionAddress(wellFunction); + const handleSetWellDetails = (details: WellDetails) => setWellDetails(details); + + return { + setLiquidity: handleSetLiquidity, + setSalt: handleSetSalt, + goNext: handleGoNext, + goBack: handleGoBack, + setPump: handleSetPump, + setWellFunction: handleSetWellFunction, + setWellDetails: handleSetWellDetails + }; + }, []); + + const setStep1: CreateWellContext["setStep1"] = useCallback( + (params) => { + setWellImplementation(params.wellImplementation); + params.goNext && methods.goNext(); + }, + [methods] + ); + + const setStep2: CreateWellContext["setStep2"] = useCallback( + (params) => { + setPumpAddress(params.pumpAddress); + setWellFunctionAddress(params.wellFunctionAddress); + setWellTokens({ + token1: params.token1, + token2: params.token2 + }); + setWellFunctionData(params.wellFunctionData); + setPumpData(params.pumpData); + setWellFunction(params.wellFunction); + params.goNext && methods.goNext(); + }, + [methods] + ); + + const setStep3: CreateWellContext["setStep3"] = useCallback( + ({ goNext, ...params }) => { + setWellDetails(params); + goNext && methods.goNext(); + }, + [methods] + ); + + const setStep4: CreateWellContext["setStep4"] = useCallback((params) => { + setDeploySalt(params.salt); + setLiquidity({ + token1Amount: params.token1Amount, + token2Amount: params.token2Amount + }); + }, []); + + /// ----- Derived State ----- + + const pump = useMemo(() => { + if (!pumpAddress || pumpAddress.toLowerCase() === "none") return; + const existing = pumps.find((p) => p.address.toLowerCase() === pumpAddress.toLowerCase()); + if (existing) return existing; + + return pumpData ? new Pump(sdk.wells, pumpAddress, pumpData) : undefined; + }, [sdk.wells, pumps, pumpAddress, pumpData]); + + /// ----- Callbacks ----- + const deployWell: CreateWellContext["deployWell"] = useCallback( + async (saltValue, liquidityAmounts) => { + setDeploying(true); + Log.module("wellDeployer").debug("Deploying Well..."); + + try { + if (!walletAddress) throw new Error("Wallet not connected"); + if (!wellImplementation) throw new Error("well implementation not set"); + if (!wellFunction) throw new Error("Well function not set"); + if (!wellTokens.token1) throw new Error("token 1 not set"); + if (!wellTokens.token2) throw new Error("token 2 not set"); + if (!wellDetails.name) throw new Error("well name not set"); + if (!wellDetails.symbol) throw new Error("well symbol not set"); + if (pumpAddress !== "none" && !pump) { + throw new Error("pump not set"); + } + + const { wellAddress } = await BoreWellUtils.boreWell( + sdk, + walletAddress, + wellImplementation, + wellFunction, + pump ? [pump] : [], + wellTokens.token1, + wellTokens.token2, + wellDetails.name, + wellDetails.symbol, + saltValue, + liquidityAmounts + ); + + clearWellsCache(); + queryClient.fetchQuery({ queryKey: ["wells", sdk] }); + + Log.module("wellDeployer").debug("Well deployed at address: ", wellAddress || ""); + setDeploying(false); + return { wellAddress: wellAddress }; + } catch (e: any) { + setDeploying(false); + console.error(e); + return e; + } + }, + [ + pumpAddress, + queryClient, + walletAddress, + wellImplementation, + wellFunction, + pump, + wellTokens.token1, + wellTokens.token2, + wellDetails.name, + wellDetails.symbol, + sdk + ] + ); + + return ( + + {children} + + ); +}; + +export const useCreateWell = () => { + const context = React.useContext(Context); + + if (!context) { + throw new Error("useCreateWell must be used within a CreateWellProvider"); + } + + return context; +}; diff --git a/projects/dex-ui/src/components/Create/CreateWellStep1.tsx b/projects/dex-ui/src/components/Create/CreateWellStep1.tsx new file mode 100644 index 0000000000..a7b83dea74 --- /dev/null +++ b/projects/dex-ui/src/components/Create/CreateWellStep1.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { Flex } from "src/components/Layout"; +import { Text } from "src/components/Typography"; + +import { FormProvider, useForm } from "react-hook-form"; +import { CreateWellStepProps, useCreateWell } from "./CreateWellProvider"; +import { ComponentInputWithCustom } from "./shared/ComponentInputWithCustom"; +import { CreateWellButtonRow } from "./shared/CreateWellButtonRow"; +import styled from "styled-components"; +import { theme } from "src/utils/ui/theme"; +import { StyledForm } from "../Form"; + +type FormValues = CreateWellStepProps["step1"]; + +const WellImplementationForm = () => { + const { wellImplementation, setStep1 } = useCreateWell(); + + const methods = useForm({ + defaultValues: { wellImplementation: wellImplementation ?? "" } + }); + + const handleSubmit = async (values: FormValues) => { + const isValidated = await methods.trigger(); + if (!isValidated) return; + + setStep1({ ...values, goNext: true }); + }; + + return ( + + + + + + + + + ); +}; + +// ---------------------------------------- + +export const CreateWellStep1 = () => { + return ( + + Create a Well - Choose a Well Implementation + + + + Deploy a Well using Aquifer, a Well factory contract. + + + It is recommended to use the Well.sol Well Implementation, but you're welcome to + use a custom contract. + + + Visit the documentation to learn more about Aquifers and Well Implementations. + + + + + {"Which Well Implementation do you want to use?".toUpperCase()} + + + + + + ); +}; + +const contentMaxWidth = "1048px"; + +const ContentWrapper = styled.div` + display: flex; + flex-direction: row; + gap: ${theme.spacing(8)}; + align-items: flex-start; + justify-content: space-between; + max-width: ${contentMaxWidth}; + width: 100%; + + ${theme.media.query.sm.only} { + flex-direction: column; + gap: ${theme.spacing(4)}; + } + + .text-section { + gap: ${theme.spacing(2)}; + max-width: 274px; + min-width: 225px; + width: 100%; + flex-shrink: 2; + + ${theme.media.query.sm.only} { + max-width: 100%; + gap: ${theme.spacing(2)}; + } + } + + .form-section { + gap: ${theme.spacing(2)}; + max-width: 710px; + width: 100%; + } +`; diff --git a/projects/dex-ui/src/components/Create/CreateWellStep2.tsx b/projects/dex-ui/src/components/Create/CreateWellStep2.tsx new file mode 100644 index 0000000000..8a1aa9ca53 --- /dev/null +++ b/projects/dex-ui/src/components/Create/CreateWellStep2.tsx @@ -0,0 +1,512 @@ +import React, { useEffect, useMemo, useState } from "react"; +import styled from "styled-components"; +import { FormProvider, useForm, useFormContext, useWatch } from "react-hook-form"; +import { getIsValidEthereumAddress } from "src/utils/addresses"; +import { theme } from "src/utils/ui/theme"; +import { Divider, Flex, FlexCard } from "src/components/Layout"; +import { Text } from "src/components/Typography"; +import { CreateWellButtonRow } from "./shared/CreateWellButtonRow"; +import { StyledForm, TextInputField } from "src/components/Form"; +import { XIcon } from "src/components/Icons"; +import { CreateWellStepProps, useCreateWell } from "./CreateWellProvider"; +import { CreateWellFormProgress } from "./shared/CreateWellFormProgress"; +import { ComponentInputWithCustom } from "./shared/ComponentInputWithCustom"; +import { useERC20TokenWithAddress } from "src/tokens/useERC20Token"; +import { ERC20Token } from "@beanstalk/sdk"; +import useSdk from "src/utils/sdk/useSdk"; +import BoreWellUtils from "src/wells/boreWell"; +import { useValidateWellFunction } from "src/wells/wellFunction/useValidateWellFunction"; +import { useBoolean } from "src/utils/ui/useBoolean"; +import { Dropdown } from "src/components/Dropdown"; +import { images } from "src/assets/images/tokens"; + +const additionalOptions = [ + { + value: "none", + label: "None", + subLabel: "No Pump" + } +]; + +type TokenFormValues = { + token1: string; + token2: string; +}; + +type OmitWellTokens = Omit; + +export type FunctionTokenPumpFormValues = OmitWellTokens & TokenFormValues; + +const tokenFormKeys = ["token1", "token2"] as const; + +const optionalKeys = ["wellFunctionData", "pumpData"] as const; + +const FunctionTokensPumpForm = () => { + const { + wellTokens, + wellFunctionAddress, + pumpAddress, + setStep2, + wellFunctionData, + pumpData, + wellFunction + } = useCreateWell(); + const sdk = useSdk(); + + const [validateWellFunction] = useValidateWellFunction(); + + const [token1, setToken1] = useState(undefined); + const [token2, setToken2] = useState(undefined); + + const methods = useForm({ + defaultValues: { + wellFunctionAddress: wellFunctionAddress || "", + wellFunctionData: wellFunctionData || "", + token1: wellTokens?.token1?.address || "", + token2: wellTokens?.token2?.address || "", + pumpAddress: pumpAddress || "", + pumpData: pumpData || "" + } + }); + + const handleSave = () => { + const values = methods.getValues(); + + setStep2({ + ...values, + token1: token1, + token2: token2, + wellFunction: wellFunction + }); + }; + + const handleSubmit = async (values: FunctionTokenPumpFormValues) => { + const valid = await methods.trigger(); + if (!valid || !token1 || !token2) return; + if (token1.address === token2.address) return; // should never be true, but just in case + const [tk1, tk2] = BoreWellUtils.prepareTokenOrderForBoreWell(sdk, [token1, token2]); + + const wellFunctionValidated = + wellFunction && + values.wellFunctionAddress.toLowerCase() === wellFunction.address.toLowerCase(); + + let validWellFunction = wellFunctionValidated ? wellFunction : undefined; + + if (!validWellFunction) { + validWellFunction = await validateWellFunction({ + address: values.wellFunctionAddress, + data: values.wellFunctionData + }); + + if (!validWellFunction) { + methods.setError("wellFunctionAddress", { message: "Invalid Well Function or Data" }); + methods.setError("wellFunctionData", { message: "Invalid Well Function or Data" }); + return; + } + } + + if (!validWellFunction.name || !validWellFunction.symbol) { + await Promise.all([validWellFunction.getName(), validWellFunction.getSymbol()]); + } + + setStep2({ + ...values, + token1: tk1, + token2: tk2, + wellFunction: validWellFunction, + goNext: true + }); + }; + + return ( + + + + + + {/* + * Well Function Form Section + */} + + + Well Functions + + Choose a Well Function to determine how the tokens in the Well get priced. + + + + + toggleMessage="Use a custom Well Implementation instead" + path="wellFunctionAddress" + dataPath="wellFunctionData" + componentType="wellFunctions" + emptyValue="" + toggleOpen={!!wellFunctionData} + /> + + + + {/* + * Token Select Section + */} + + Tokens + + {tokenFormKeys.map((path) => { + const setToken = path === "token1" ? setToken1 : setToken2; + const typeText = path === "token1" ? "Token 1 Type" : "Token 2 Type"; + return ( +
+ + + {typeText} + + + ERC-20 + + + + + Specify token + + + +
+ ); + })} +
+
+ + {/* + * Pump Select Section + */} + + + Pumps + Choose Pump(s) to set up a price feed from your Well. + + + + componentType="pumps" + path="pumpAddress" + dataPath="pumpData" + toggleMessage="Use a custom Pump" + emptyValue="" + additional={additionalOptions} + toggleOpen={!!pumpData} + /> + + + {/* + * Actions + */} + +
+
+
+
+ ); +}; + +// ---------------------------------------- + +export const CreateWellStep2 = () => { + return ( + +
+ Create a Well - Choose a Well Function and Pump + Select the components to use in your Well. +
+ +
+ ); +}; + +// ---------- STYLES & COMPONENTS ---------- + +const uniqueTokensRequiredErrMessage = "Unique tokens required"; + +type TokenAddressInputWithSearchProps = { + path: "token1" | "token2"; + setToken: React.Dispatch>; +}; +const TokenAddressInputWithSearch = ({ path, setToken }: TokenAddressInputWithSearchProps) => { + const counterPath = path === "token1" ? "token2" : "token1"; + const sdk = useSdk(); + const [open, { set: setOpen }] = useBoolean(); + + const { + register, + control, + setValue, + formState: { + errors: { [path]: formError, [counterPath]: counterFormError } + }, + clearErrors + } = useFormContext(); + + const value = useWatch({ control, name: path }); + const { data: token, error, isLoading } = useERC20TokenWithAddress(value); + + const erc20ErrMessage = error?.message; + const formErrMessage = formError?.message; + const counterFormErrMessage = counterFormError?.message; + + const tokenAndFormValueMatch = Boolean( + token && token.address.toLowerCase() === value.toLowerCase() + ); + + useEffect(() => { + if (token && tokenAndFormValueMatch) { + setToken(token); + } else { + setToken(undefined); + } + }, [token, tokenAndFormValueMatch, setToken]); + + useEffect(() => { + if (counterFormErrMessage === uniqueTokensRequiredErrMessage) { + if (formErrMessage !== uniqueTokensRequiredErrMessage) { + clearErrors(counterPath); + } + } + }, [counterFormErrMessage, formErrMessage, counterPath, clearErrors]); + + const options = useMemo( + () => [sdk.tokens.BEAN, sdk.tokens.DAI, sdk.tokens.USDC, sdk.tokens.USDT, sdk.tokens.WETH], + [sdk] + ); + + const filteredOptions = useMemo(() => { + const val = value?.toLowerCase() || ""; + return options.filter((token) => { + const symbolMatch = token.symbol.toLowerCase().includes(val); + const addressMatch = token.address.toLowerCase().includes(val); + const nameMatch = token.name.toLowerCase().includes(val); + return symbolMatch || addressMatch || nameMatch; + }); + }, [options, value]); + + return ( + <> + + { + setOpen(_open); + }} + offset={-43} + trigger={ + + + + } + > + + { + return getIsValidEthereumAddress(formValue) || "Invalid address"; + }, + tokensAreSame: (formValue, formValues) => { + const counterToken = formValues[path === "token1" ? "token2" : "token1"]; + const tokensAreSame = formValue.toLowerCase() === counterToken.toLowerCase(); + return !tokensAreSame || "Unique tokens required"; + } + }, + onBlur: (e) => e.currentTarget.focus() // prevent this input field from going out of focus + })} + placeholder="Search for token or input an address" + startIcon="search" + error={formErrMessage ?? erc20ErrMessage} + /> + + + {filteredOptions.map((token) => { + return ( + { + setValue(path, token.address.toLowerCase()); + }} + > + + + {{value}} + + {token.symbol} + + + ); + })} + + + {token && !isLoading && ( + + {token?.logo && ( + + {{value}} + + )} + + {token?.symbol} + {" "} + setValue(path, "")}> + + + + )} + + + ); +}; + +const FormInnerWrapper = styled.div` + display: flex; + flex-direction: row; + gap: ${theme.spacing(6)}; + + ${theme.media.query.sm.only} { + flex-direction: column; + gap: ${theme.spacing(4)}; + } +`; + +const TokenSelectWrapper = styled(Flex)` + width: 100%; + flex-direction: row; + gap: ${theme.spacing(4)}; + + ${theme.media.query.md.down} { + flex-direction: column; + gap: ${theme.spacing(3)}; + } + + .input-wrapper { + display: flex; + flex-direction: column; + width: 50%; + max-width: 50%; + gap: ${theme.spacing(2)}; + + ${theme.media.query.md.down} { + width: 100%; + max-width: 100%; + } + } +`; + +const TokenInputWrapper = styled(Flex)` + position: relative; +`; + +const DropdownContent = styled(Flex)` + border: 1px solid ${theme.colors.black}; + border-top: none; + + &:not(:last-child) { + border-bottom: 1px solid ${theme.colors.lightGray}; + } +`; + +const TokenSelectItemWrapper = styled.div` + position: relative; + display: flex; + flex-direction: row; + align-items: center; + gap: ${theme.spacing(1)}; + + .item-token-symbol { + position: relative; + top: 2px; + } +`; + +const FoundTokenInfo = styled.div` + position: absolute; + box-sizing: border-box; + width: 100%; + display: flex; + flex-direction: row; + gap: ${theme.spacing(1)}; + align-items: center; + border: 1px solid ${theme.colors.black}; + background: ${theme.colors.primaryLight}; + padding: 9px 16px; + + .token-symbol { + position: relative; + top: 1px; + } + + svg { + cursor: pointer; + } +`; + +const SectionWrapper = styled(Flex)` + display: flex; + flex-direction: row; + width: 100%; + justify-content: space-between; + gap: ${theme.spacing(2)}; + + ${theme.media.query.md.down} { + flex-direction: column; + gap: ${theme.spacing(3)}; + } + + .description { + max-width: 180px; + + ${theme.media.query.md.down} { + max-width: 100%; + } + } + + .form-section { + max-width: 713px; + width: 100%; + + ${theme.media.query.md.down} { + max-width: 100%; + } + } +`; + +const ImgContainer = styled.div<{ + width: number; + height: number; +}>` + display: flex; + justify-content: center; + align-items: center; + + width: ${(props) => props.width}px; + height: ${(props) => props.height}px; + img { + width: ${(props) => props.width}px; + height: ${(props) => props.height}px; + } +`; + +const Subtitle = styled(Text)` + margin-top: ${theme.spacing(0.5)}; + color: ${theme.colors.stone}; +`; diff --git a/projects/dex-ui/src/components/Create/CreateWellStep3.tsx b/projects/dex-ui/src/components/Create/CreateWellStep3.tsx new file mode 100644 index 0000000000..c39b09969b --- /dev/null +++ b/projects/dex-ui/src/components/Create/CreateWellStep3.tsx @@ -0,0 +1,170 @@ +import React from "react"; +import { Divider, Flex } from "src/components/Layout"; +import { Text } from "src/components/Typography"; +import { theme } from "src/utils/ui/theme"; +import styled from "styled-components"; +import { CreateWellStepProps, useCreateWell } from "./CreateWellProvider"; +import { FormProvider, useForm } from "react-hook-form"; +import { CreateWellFormProgress } from "./shared/CreateWellFormProgress"; +import { StyledForm, TextInputField } from "../Form"; +import { useWells } from "src/wells/useWells"; +import { CreateWellButtonRow } from "./shared/CreateWellButtonRow"; + +export type WellDetailsFormValues = CreateWellStepProps["step3"]; + +// If the user goes back to step 2 changes the well function & returns to this step, +// the default values will not be updated. +// TODO: priofity sm. +const useWellDetailsDefaultValues = () => { + const { wellTokens, wellFunction } = useCreateWell(); + + const wellName = wellFunction?.name; + const wellSymbol = wellFunction?.symbol; + const token1 = wellTokens?.token1?.symbol; + const token2 = wellTokens?.token2?.symbol; + + const defaultName = + wellName && token1 && token2 ? `${token1}:${token2} ${wellName} Well` : undefined; + + const defaultSymbol = wellSymbol && token1 && token2 && `${token1}${token2}${wellSymbol}w`; + + return { + name: defaultName, + symbol: defaultSymbol + }; +}; + +const NameAndSymbolForm = () => { + const { data: wells } = useWells(); + const { wellDetails, setStep3 } = useCreateWell(); + const defaults = useWellDetailsDefaultValues(); + + const methods = useForm({ + defaultValues: { + name: wellDetails?.name || defaults?.name || "", + symbol: wellDetails?.symbol || defaults?.symbol || "" + } + }); + + const handleSave = () => { + const values = methods.getValues(); + setStep3(values); + }; + + const onSubmit = async (values: WellDetailsFormValues) => { + const valid = await methods.trigger(); + if (!valid) return; + setStep3({ ...values, goNext: true }); + }; + + return ( + + + + + +
+ + Name and Symbol + + + + + Well Token Name + + { + const duplicate = (wells || []).some( + (well) => well.name?.toLowerCase() === value.toLowerCase() + ); + + return !duplicate || "Token name taken"; + } + })} + error={methods.formState.errors.name?.message as string | undefined} + /> + + + + Well Token Symbol + + { + const duplicate = (wells || []).some( + (well) => well?.lpToken?.symbol.toLowerCase() === value.toLowerCase() + ); + return !duplicate || "Token symbol taken"; + } + })} + error={methods.formState.errors.symbol?.message as string | undefined} + /> + + +
+ + +
+
+
+
+ ); +}; + +export const CreateWellStep3 = () => { + return ( + +
+ Well Name and Symbol + Give your Well LP token a name and a symbol. +
+ +
+ ); +}; + +const FormInnerWrapper = styled.div` + display: flex; + flex-direction: row; + gap: ${theme.spacing(6)}; + + ${theme.media.query.sm.only} { + flex-direction: column; + gap: ${theme.spacing(4)}; + } + + .component-inputs-wrapper { + flex-direction: row; + width: 100%; + gap: ${theme.spacing(4)}; + + .input-wrapper { + width: 50%; + max-width: 50%; + } + + ${theme.media.query.sm.only} { + gap: ${theme.spacing(2)}; + flex-direction: column; + + .input-wrapper { + width: 100%; + max-width: unset; + } + } + } +`; + +const Subtitle = styled(Text)` + margin-top: ${theme.spacing(0.5)}; + color: ${theme.colors.stone}; +`; diff --git a/projects/dex-ui/src/components/Create/CreateWellStep4.tsx b/projects/dex-ui/src/components/Create/CreateWellStep4.tsx new file mode 100644 index 0000000000..90bc2568f7 --- /dev/null +++ b/projects/dex-ui/src/components/Create/CreateWellStep4.tsx @@ -0,0 +1,593 @@ +import React, { useEffect, useState } from "react"; +import styled from "styled-components"; +import { + Control, + Controller, + FormProvider, + useForm, + useFormContext, + useWatch +} from "react-hook-form"; +import { theme } from "src/utils/ui/theme"; + +import { StyledForm, SwitchField, TextInputField } from "src/components/Form"; +import { Box, Divider, Flex, FlexCard } from "src/components/Layout"; +import { SelectCard } from "src/components/Selectable"; +import { Text } from "src/components/Typography"; + +import { CreateWellContext, CreateWellStepProps, useCreateWell } from "./CreateWellProvider"; +import { WellComponentInfo, useWhitelistedWellComponents } from "./useWhitelistedWellComponents"; + +import { ERC20Token, TokenValue } from "@beanstalk/sdk"; +import { TokenInput } from "src/components/Swap/TokenInput"; +import { CreateWellButtonRow } from "./shared/CreateWellButtonRow"; +import { useTokenAllowance } from "src/tokens/useTokenAllowance"; +import useSdk from "src/utils/sdk/useSdk"; +import { ButtonPrimary } from "../Button"; +import { ensureAllowance } from "../Liquidity/allowance"; +import { useAccount } from "wagmi"; +import { useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "src/utils/query/queryKeys"; +import { useBoolean } from "src/utils/ui/useBoolean"; +import { ProgressCircle } from "../ProgressCircle"; +import { useNavigate } from "react-router-dom"; +import { Modal } from "../Modal"; + +type FormValues = CreateWellStepProps["step4"] & { + usingSalt: boolean; + seedingLiquidity: boolean; +}; + +type FormContentProps = { + salt: number | undefined; + liquidity: CreateWellContext["liquidity"]; + token1: ERC20Token; + token2: ERC20Token; + deploying: boolean; + setStep4: CreateWellContext["setStep4"]; + deployWell: CreateWellContext["deployWell"]; +}; + +const FormContent = ({ + token1, + token2, + salt, + liquidity, + deploying, + setStep4, + deployWell +}: FormContentProps) => { + const [enoughAllowance, setEnoughAllowance] = useState(true); + const [modalOpen, { set: setModal }] = useBoolean(false); + const [deployedWellAddress, setDeployedWellAddress] = useState(""); + const [deployErr, setDeployErr] = useState(); + const navigate = useNavigate(); + + const methods = useForm({ + defaultValues: { + usingSalt: !!salt, + salt: salt, + seedingLiquidity: !!(liquidity.token1Amount || liquidity.token2Amount), + token1Amount: liquidity.token1Amount?.toString() || "", + token2Amount: liquidity.token2Amount?.toString() || "" + } + }); + + const [seeding, _amt1, _amt2] = methods.watch(['seedingLiquidity', 'token1Amount', 'token2Amount']); + + const amt1 = Number(_amt1 || 0); + const amt2 = Number(_amt2 || 0); + const bothAmountsNeeded = seeding ? (amt1 > 0 && amt2 <= 0) || (amt1 <= 0 && amt2 > 0) : false; + + const handleSave = (formValues?: FormValues) => { + const values = formValues || methods.getValues(); + setStep4({ + salt: values.usingSalt ? values.salt : undefined, + token1Amount: values.seedingLiquidity ? values.token1Amount : undefined, + token2Amount: values.seedingLiquidity ? values.token2Amount : undefined + }); + }; + + const onSubmit = async (values: FormValues) => { + setDeployErr(undefined); + setModal(true); + setStep4({ + salt: values.usingSalt ? values.salt : undefined, + token1Amount: values.token1Amount, + token2Amount: values.token2Amount + }); + + if (bothAmountsNeeded) { + return; + } + + const token1Amount = token1.fromHuman(Number(values.token1Amount || "0")); + const token2Amount = token2.fromHuman(Number(values.token2Amount || "0")); + + // We determine that the user is seeding liquidity if they have 'seeding liquidity' toggled on in the CURRENT form + // and if they have provided a non-zero amount for BOTH tokens. + const seedingLiquidity = + values.seedingLiquidity && Boolean(token1Amount.gt(0) && token2Amount.gt(0)); + + // Always use the salt value from the current form. + const saltValue = (values.usingSalt && values.salt) || 0; + + const liquidity = + seedingLiquidity && token1Amount && token2Amount ? { token1Amount, token2Amount } : undefined; + + const result = await deployWell(saltValue, liquidity); + if ("wellAddress" in result) { + setDeployedWellAddress(result.wellAddress); + navigate(`/wells/${result.wellAddress}`); + } else { + setDeployErr(result); + } + }; + + return ( + <> + + + + + + + + + + + + + Well Deployment In Progress + + + + + {deployErr ? ( + + Transaction Reverted: + + {deployErr.message || "See console for details"} + + + ) : null} + + + + + + ); +}; + +const ErroMessageWrapper = styled(Flex)` + overflow: auto; + max-height: 300px; + overflow-wrap: anywhere; +`; + +const ModalContentWrapper = styled(Flex)` + min-width: 400px; + + ${theme.media.query.sm.only} { + min-width: min(calc(100vw - 96px), 400px); + max-width: min(calc(100vw - 96px), 400px); + width: 100%; + } +`; + +type LiquidityFormProps = { + token1: ERC20Token; + token2: ERC20Token; + setHasEnoughAllowance: React.Dispatch>; +}; +const LiquidityForm = ({ token1, token2, setHasEnoughAllowance }: LiquidityFormProps) => { + const { control } = useFormContext(); + const seedingLiquidity = useWatch({ control, name: "seedingLiquidity" }); + + return ( + + + + + Seed Well with initial liquidity + + + {seedingLiquidity && ( + + { + return ( + { + field.onChange(value.toHuman()); + }} + canChangeToken={false} + loading={false} + label="" + allowNegative={false} + balanceLabel="Available" + clamp + /> + ); + }} + /> + { + return ( + { + field.onChange(value.toHuman()); + }} + canChangeToken={false} + loading={false} + label="" + allowNegative={false} + balanceLabel="Available" + clamp + /> + ); + }} + /> + + + )} + + ); +}; + +const AllowanceButtons = ({ + token1, + token2, + control, + seedingLiquidity, + setHasEnoughAllowance +}: LiquidityFormProps & { + control: Control; + seedingLiquidity: boolean; +}) => { + const { address } = useAccount(); + const sdk = useSdk(); + const queryClient = useQueryClient(); + + const { data: token1Allowance } = useTokenAllowance(token1, sdk.contracts.beanstalk.address); + const { data: token2Allowance } = useTokenAllowance(token2, sdk.contracts.beanstalk.address); + + const amount1 = useWatch({ control, name: "token1Amount" }); + const amount2 = useWatch({ control, name: "token2Amount" }); + + const amount1ExceedsAllowance = token1Allowance && amount1 && token1Allowance.lt(Number(amount1)); + const amount2ExceedsAllowance = token2Allowance && amount2 && token2Allowance.lt(Number(amount2)); + + const approveToken = async (token: ERC20Token, amount: TokenValue) => { + if (!address) return; + await ensureAllowance(address, sdk.contracts.beanstalk.address, token, amount); + queryClient.invalidateQueries({ + queryKey: queryKeys.tokenAllowance(token.address, sdk.contracts.beanstalk.address) + }); + }; + + useEffect(() => { + if (seedingLiquidity && (amount1ExceedsAllowance || amount2ExceedsAllowance)) { + setHasEnoughAllowance(false); + return; + } + + setHasEnoughAllowance(true); + }, [amount1ExceedsAllowance, seedingLiquidity, amount2ExceedsAllowance, setHasEnoughAllowance]); + + if (!amount1ExceedsAllowance && !amount2ExceedsAllowance) { + return null; + } + + return ( + + {amount1ExceedsAllowance && ( + { + // prevent form submission + e.preventDefault(); + e.stopPropagation(); + approveToken(token1, token1.amount(amount1)); + }} + > + Approve {token1.symbol} + + )} + {amount2ExceedsAllowance && ( + { + // prevent form submission + e.preventDefault(); + e.stopPropagation(); + approveToken(token2, token2.amount(amount2)); + }} + > + Approve {token2.symbol} + + )} + + ); +}; + +const useSeedingLiquidity = () => { + const { control, setValue } = useFormContext(); + + const seedingLiquidity = useWatch({ control, name: "seedingLiquidity" }); + const amount1 = useWatch({ control, name: "token1Amount" }); + const amount2 = useWatch({ control, name: "token2Amount" }); + const salt = useWatch({ control, name: "salt" }); + + const noAmounts = !amount1 && !amount2; + const noSaltValue = !salt; + + const isSeedingLiquidityAndHasValues = seedingLiquidity && !noAmounts; + + // Conditionally toggle 'usingSalt' field based on seeding liquidity and salt values + useEffect(() => { + if (seedingLiquidity) { + setValue("usingSalt", true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [seedingLiquidity]); + + useEffect(() => { + if (!seedingLiquidity && noSaltValue && noAmounts) { + setValue("usingSalt", false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [noSaltValue, seedingLiquidity, noAmounts]); + + return { + isSeedingLiquidityAndHasValues, + seedingLiquidityToggled: seedingLiquidity + } as const; +}; + +const SaltForm = () => { + const { + control, + register, + formState: { + errors: { salt: saltError } + } + } = useFormContext(); + const usingSalt = useWatch({ control, name: "usingSalt" }); + const { isSeedingLiquidityAndHasValues, seedingLiquidityToggled } = useSeedingLiquidity(); + + return ( + + + + + + Deploy Well with a Salt + + + {usingSalt ? ( + + New Wells are deployed using pipeline. Salt should be mined with the pipeline address + + ) : null} + + {usingSalt && ( + = 1 when seeding liquidity" + }, + validate: (formValue) => { + if (formValue && !Number.isInteger(Number(formValue))) { + return "Salt must be an integer"; + } + return true; + } + })} + error={saltError?.message as string | undefined} + /> + )} + + ); +}; + +// ---------------------------------------- + +export const CreateWellStep4 = () => { + const { components } = useWhitelistedWellComponents(); + const { + wellImplementation, + pumpAddress, + wellFunctionAddress, + wellTokens: { token1, token2 }, + wellDetails: { name: wellName, symbol: wellSymbol }, + salt, + liquidity, + loading, + setStep4, + deployWell + } = useCreateWell(); + + if ( + !wellImplementation || + !pumpAddress || + !wellFunctionAddress || + !token1 || + !token2 || + !wellName || + !wellSymbol + ) { + return null; + } + + return ( + +
+ Preview Deployment + Review selections and deploy your Well. +
+ + + {/* well implementation */} + + Well Implementation + + + {/* name & symbol */} + + Well Name & Symbol + + Name:{" "} + + {wellName} + + + + Symbol:{" "} + + {wellSymbol} + + + + {/* Tokens */} + + Tokens + + {token1.name + {token1?.symbol ?? ""} + + + {token2.name + {token2?.symbol ?? ""} + + + {/* Pricing Function */} + + Pricing Function + + + + Pumps + + + + + + + + +
+ ); +}; + +// ---------------------------------------- + +// shared components +const getSelectedCardComponentProps = ( + address: string, + components: readonly WellComponentInfo[] +): { title: string; subtitle?: string } | undefined => { + const component = components.find((c) => c.address.toLowerCase() === address.toLowerCase()); + + return { + title: component?.component.name ?? address, + subtitle: component?.component.summary + }; +}; + +const SelectedComponentCard = ({ title, subtitle }: { title?: string; subtitle?: string }) => { + if (!title && !subtitle) return null; + + return ( + +
+ + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} +
+
+ ); +}; + +const Subtitle = styled(Text).attrs({ $mt: 0.5 })` + color: ${theme.colors.stone}; +`; + +const InlineImgFlex = styled(Flex).attrs({ + $display: "inline-flex", + $direction: "row", + $gap: 0.5 +})` + img { + width: 20px; + height: 20px; + max-width: 20px; + max-height: 20px; + min-width: 20px; + min-height: 20px; + } +`; diff --git a/projects/dex-ui/src/components/Create/index.ts b/projects/dex-ui/src/components/Create/index.ts new file mode 100644 index 0000000000..9f60b138f2 --- /dev/null +++ b/projects/dex-ui/src/components/Create/index.ts @@ -0,0 +1,5 @@ +export * from "./CreateWellStep1"; +export * from "./CreateWellStep2"; +export * from "./CreateWellStep3"; +export * from "./CreateWellStep4"; +export * from "./CreateWellProvider"; diff --git a/projects/dex-ui/src/components/Create/shared/ComponentAccordionCard.tsx b/projects/dex-ui/src/components/Create/shared/ComponentAccordionCard.tsx new file mode 100644 index 0000000000..d8f4432b52 --- /dev/null +++ b/projects/dex-ui/src/components/Create/shared/ComponentAccordionCard.tsx @@ -0,0 +1,163 @@ +import React from "react"; +import styled from "styled-components"; +import { Link } from "react-router-dom"; +import { theme } from "src/utils/ui/theme"; +import { Box, Flex } from "src/components/Layout"; +import { Text } from "src/components/Typography"; +import { WellComponentInfo } from "../useWhitelistedWellComponents"; +import { AccordionSelectCard } from "../../Selectable"; +import { Etherscan, Github } from "../../Icons"; + +export type WellComponentAccordionCardProps = { + selected: boolean; + setSelected: (address: string) => void; +} & WellComponentInfo; + +export const WellComponentAccordionCard = ({ + selected, + address, + component, + info, + links, + setSelected +}: WellComponentAccordionCardProps) => { + return ( + + + + {component.fullName || component.name}{" "} + + {"(Recommended)"} + + + {component.description.map((text, j) => ( + + {text} + + ))} + + + } + below={ + + + {info.map((datum) => + Array.isArray(datum.value) ? ( + + {datum.label}:{" "} + {datum.value.map((value, index) => ( + + {value.imgSrc && } + + + {" "} + {value.value} + {index !== datum.value.length - 1 ? "," : ""} + + + + ))} + + ) : ( + + {datum.label}: {datum.imgSrc && } + + + {" "} + {datum.value} + + + + ) + )} + + Used by {component.usedBy} other {toPlural("Well", component.usedBy ?? 0)} + + + + + {links.etherscan && ( + + + + )} + {links.github && ( + + + + )} + + {links.learnMore ? ( + + + Learn more about this component + + + ) : null} + + + } + onClick={() => setSelected(address)} + defaultExpanded + /> + ); +}; + +const MayLink = ({ url, children }: { url?: string; children: React.ReactNode }) => { + if (url) { + return ( + { + e.stopPropagation(); + }} + > + {children} + + ); + } + return children; +}; + +const LinkFormWrapperInner = styled(Link).attrs({ + target: "_blank", + rel: "noopener noreferrer" +})` + text-decoration: none; + outline: none; +`; + +const IconImg = styled.img<{ $rounded?: boolean }>` + max-height: 16px; + max-width: 16px; + border-radius: 50%; + margin-bottom: ${theme.spacing(-0.25)}; +`; + +const toPlural = (word: string, count: number) => { + const suffix = count === 1 ? "" : "s"; + return `${word}${suffix}`; +}; diff --git a/projects/dex-ui/src/components/Create/shared/ComponentInputWithCustom.tsx b/projects/dex-ui/src/components/Create/shared/ComponentInputWithCustom.tsx new file mode 100644 index 0000000000..2f132349e4 --- /dev/null +++ b/projects/dex-ui/src/components/Create/shared/ComponentInputWithCustom.tsx @@ -0,0 +1,174 @@ +import React, { useCallback } from "react"; +import { FieldValues, Path, PathValue, useFormContext, useWatch } from "react-hook-form"; +import { useWhitelistedWellComponents } from "../useWhitelistedWellComponents"; +import { useBoolean } from "src/utils/ui/useBoolean"; +import { TextInputField } from "../../Form"; +import { Flex } from "src/components/Layout"; +import { ToggleSwitch } from "src/components/ToggleSwitch"; +import { WellComponentAccordionCard } from "./ComponentAccordionCard"; +import { Text } from "src/components/Typography"; +import { theme } from "src/utils/ui/theme"; +import styled from "styled-components"; +import { CircleFilledCheckIcon, CircleEmptyIcon } from "../../Icons"; +import { getIsValidEthereumAddress } from "src/utils/addresses"; + +type AdditionalOptionProps = { + value: string; + label: string; + subLabel?: string; +}; + +type Props = { + path: Path; + dataPath?: Path; + componentType: keyof ReturnType["components"]; + toggleMessage: string; + emptyValue: PathValue>; + additional?: AdditionalOptionProps[]; + toggleOpen?: boolean; +}; + +export const ComponentInputWithCustom = ({ + componentType, + path, + dataPath, + toggleMessage, + emptyValue, + toggleOpen = false, + additional +}: Props) => { + const { + components: { [componentType]: wellComponents } + } = useWhitelistedWellComponents(); + const [usingCustom, { toggle, set: setUsingCustom }] = useBoolean(toggleOpen); + + const { + control, + setValue, + register, + formState: { + errors: { [path]: error } + } + } = useFormContext(); + + const value = useWatch({ control, name: path }); + + const handleSetValue = useCallback( + (_addr: string) => { + const newValue = (_addr === value ? emptyValue : _addr) as PathValue>; + setUsingCustom(false); + setValue(path, newValue, { shouldValidate: true }); + }, + [value, path, , emptyValue, setValue, setUsingCustom] + ); + + const handleToggle = useCallback(() => { + setValue(path, emptyValue); + if (dataPath) { + setValue(dataPath, emptyValue); + } + toggle(); + }, [setValue, toggle, path, emptyValue, dataPath]); + + // we can always assume that error.message is a string b/c we define the + // validation here in this component + const errMessage = (error?.message || "") as string | undefined; + + return ( + <> + {wellComponents.map((data, i) => ( + + ))} + {additional?.map((option, i) => { + const selected = !usingCustom && option.value === value; + return ( + handleSetValue(option.value)} + > + + {selected ? ( + + ) : ( + + )} +
+ + {option.label} + + {option.subLabel && ( + + {option.subLabel} + + )} +
+
+
+ ); + })} + + + + {toggleMessage} + + + {usingCustom && ( + <> + { + return getIsValidEthereumAddress(_value) || "Invalid address"; + } + })} + placeholder="Input address" + error={errMessage} + /> + {dataPath && } + + )} + + ); +}; + +const ComponentDataFieldInput = (props: { path: Path }) => { + const { + register, + formState: { + errors: { [props.path]: error } + } + } = useFormContext(); + + const errMessage = (error?.message || "") as string | undefined; + + return ( + { + return _value.startsWith("0x") || "Invalid input"; + } + })} + placeholder="0x data" + error={errMessage} + /> + ); +}; + +const AdditionalOption = styled(Flex)<{ $active: boolean }>` + border: 1px solid ${(props) => (props.$active ? theme.colors.black : theme.colors.lightGray)}; + background: ${(props) => (props.$active ? theme.colors.primaryLight : theme.colors.white)}; + padding: ${theme.spacing(2, 3)}; + cursor: pointer; + + .svg-wrapper { + min-width: 16px; + min-height: 16px; + } +`; diff --git a/projects/dex-ui/src/components/Create/shared/CreateWellButtonRow.tsx b/projects/dex-ui/src/components/Create/shared/CreateWellButtonRow.tsx new file mode 100644 index 0000000000..e6c769bfcd --- /dev/null +++ b/projects/dex-ui/src/components/Create/shared/CreateWellButtonRow.tsx @@ -0,0 +1,120 @@ +import React, { useMemo } from "react"; +import { useFormContext, useWatch } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { theme } from "src/utils/ui/theme"; +import styled from "styled-components"; +import { ButtonPrimary } from "../../Button"; +import { LeftArrow, RightArrow } from "../../Icons"; +import { Flex } from "../../Layout"; +import { useCreateWell } from "../CreateWellProvider"; +import { ActionWalletButtonWrapper } from "src/components/Wallet"; + +const ButtonLabels = [ + { + back: "Back", + next: "Next: Customize Well" + }, + { + back: "Back: Choose Well Implementation", + next: "Next: Well Name and Symbol" + }, + { + back: "Back: Customize Well", + next: "Next: Preview Deployment" + }, + { + back: "Back: Customize Well", + next: "Deploy Well" + } +] as const; + +export const CreateWellButtonRow = ({ + disabled = false, + valuesRequired = true, + optionalKeys, + disabledMessage, + onGoBack +}: { + disabled?: boolean; + optionalKeys?: readonly string[] | string[]; + valuesRequired?: boolean; + disabledMessage?: string; + onGoBack?: () => void; +}) => { + const { step, goBack } = useCreateWell(); + + const navigate = useNavigate(); + const { + control, + formState: { errors, isSubmitting } + } = useFormContext(); + const values = useWatch({ control }); + + const handleGoBack = () => { + onGoBack?.(); + if (step === 0) { + navigate("/build"); + } else { + goBack(); + } + }; + + const noErrors = !Object.keys(errors).length; + + const hasRequiredValues = useMemo(() => { + if (!valuesRequired) return true; + const baseKeys = Object.keys(values); + const keys = optionalKeys ? baseKeys.filter((key) => !optionalKeys.includes(key)) : baseKeys; + + return keys.every((key) => Boolean(values[key])); + }, [valuesRequired, optionalKeys, values]); + + const goNextEnabled = noErrors && hasRequiredValues; + + const goBackLabel = ButtonLabels[step].back || "Back"; + const nextLabel = disabled && disabledMessage || ButtonLabels[step].next || "Next"; + + return ( + + { + // stop the event from bubbling up + e.preventDefault(); + e.stopPropagation(); + handleGoBack(); + }} + > + + + {goBackLabel} + + + + + + {nextLabel} + + + + + + ); +}; + +const ButtonLabel = styled(Flex).attrs({ + $gap: 1, + $direction: "row", + $alignItems: "center", + $justiyContent: "center" +})` + svg { + margin-bottom: 2px; + } + + ${theme.media.query.sm.only} { + svg { + display: none; + } + } +`; diff --git a/projects/dex-ui/src/components/Create/shared/CreateWellFormProgress.tsx b/projects/dex-ui/src/components/Create/shared/CreateWellFormProgress.tsx new file mode 100644 index 0000000000..27ceddd0b2 --- /dev/null +++ b/projects/dex-ui/src/components/Create/shared/CreateWellFormProgress.tsx @@ -0,0 +1,136 @@ +import React, { useMemo } from "react"; +import { useFormContext, useWatch } from "react-hook-form"; +import { theme } from "src/utils/ui/theme"; +import { CheckIcon, CircleEmptyIcon } from "src/components/Icons"; +import { Flex } from "src/components/Layout"; +import { Link } from "react-router-dom"; +import styled from "styled-components"; +import { Text } from "src/components/Typography"; +import { FunctionTokenPumpFormValues } from "../CreateWellStep2"; +import { WellDetailsFormValues } from "../CreateWellStep3"; + +type ViableProps = Omit & + WellDetailsFormValues; + +const progressOrder = { + // Well Function & Pump Steps + ["Select Well Function"]: 0, + ["Select Tokens"]: 1, + ["Select Pump(s)"]: 2, + + // Well Name & Symbol Steps + ["Well Name"]: 0, + ["Well Symbol"]: 1 +} as const; + +type OrderKey = keyof typeof progressOrder; + +const progressLabelMap: Record = { + // Well Function & Pump Steps + wellFunctionAddress: "Select Well Function", + token1: "Select Tokens", + token2: "Select Tokens", + pumpAddress: "Select Pump(s)", + // Well Name & Symbol Steps + name: "Well Name", + symbol: "Well Symbol" +} as const; + +export const CreateWellFormProgress = () => { + const { + control, + formState: { errors } + } = useFormContext(); + const values = useWatch({ control: control }); + + const labelToProgress = useMemo(() => { + const progressMap = {} as Record; + + // We assume that 'defaultValue' is always passed into the form. Otherwise + for (const key in values) { + if (!(key in progressLabelMap)) continue; + const progressKey = progressLabelMap[key as keyof typeof progressLabelMap]; + + const value = values[key as keyof typeof values]; + const hasError = Boolean(key in errors && errors[key]?.message); + + const isFinished = Boolean(value) && !hasError; + + if (progressKey && progressKey in progressMap) { + progressMap[progressKey] = progressMap[progressKey] && isFinished; + } else { + progressMap[progressKey] = isFinished; + } + } + + return Object.entries(progressMap).sort(([_aKey], [_bKey]) => { + const a = progressOrder[_aKey as OrderKey]; + const b = progressOrder[_bKey as OrderKey]; + return a - b; + }); + }, [errors, values]); + + return ( + + + {labelToProgress.map(([label, checked], i) => ( + + ))} + + + Visit the component library to learn more about the + different Well components. + + + ); +}; + +const IndicatorWithLabel = ({ label, checked }: { label: string; checked: boolean }) => { + return ( + + + {label} + + {checked ? ( + + ) : ( + + + + )} + + ); +}; + +const NudgeLeft = styled.div` + display: flex; + margin-right: 5px; +`; + +const ProgressContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${theme.spacing(4)}; + max-width: 182px; + width: 100%; + + .progress-indicators { + gap: ${theme.spacing(2)}; + } + + ${theme.media.query.sm.only} { + gap: ${theme.spacing(2)}; + flex-direction: column-reverse; + max-width: unset; + + .progress-indicators { + gap: ${theme.spacing(1)}; + } + } +`; + +const TextLink = styled(Link)` + ${theme.font.styles.variant("xs")} + color: ${theme.font.color("text.light")}; + text-decoration: underline; +`; diff --git a/projects/dex-ui/src/components/Create/useWhitelistedWellComponents.ts b/projects/dex-ui/src/components/Create/useWhitelistedWellComponents.ts new file mode 100644 index 0000000000..20e1fc15e9 --- /dev/null +++ b/projects/dex-ui/src/components/Create/useWhitelistedWellComponents.ts @@ -0,0 +1,241 @@ +import { useMemo } from "react"; +import BeanstalkFarmsLogo from "src/assets/images/beanstalk-farms.png"; +import HalbornLogo from "src/assets/images/halborn-logo.png"; +import { + MULTI_FLOW_PUMP_ADDRESS, + CONSTANT_PRODUCT_2_ADDRESS, + WELL_DOT_SOL_ADDRESS, + toAddressMap +} from "src/utils/addresses"; +import BrendanTwitterPFP from "src/assets/images/brendan-twitter-pfp.png"; +import CyrfinLogo from "src/assets/images/cyrfin-logo.svg"; +import Code4renaLogo from "src/assets/images/code4rena-logo.png"; +import ClockIcon from "src/assets/images/clock-icon.svg"; +import { useWells } from "src/wells/useWells"; +import { useWellImplementations } from "src/wells/useWellImplementations"; +import { useWellFunctions } from "src/wells/wellFunction/useWellFunctions"; +import { usePumps } from "src/wells/pump/usePumps"; +import { AddressMap } from "src/types"; + +export enum WellComponentType { + WellImplementation = "WellImplementation", + Pump = "Pump", + WellFunction = "WellFunction" +} + +type BaseInfo = { + value: string; + imgSrc?: string; + url?: string; +}; + +type ComponentInfo = Omit & { + label: string; + value: string | BaseInfo[]; +}; + +export type WellComponentInfo = { + address: string; + component: { + name: string; + fullName?: string; + summary: string; + description: string[]; + url?: string; + usedBy: number; + type: { + type: WellComponentType; + display: string; + imgSrc?: string; + }; + }; + info: ComponentInfo[]; + links: { + etherscan?: string; + github?: string; + learnMore?: string; + }; +}; + +const code4ArenaAuditLink = "https://code4rena.com/reports/2023-07-basin"; +const halbornAuditLink = + "https://github.com/BeanstalkFarms/Beanstalk-Audits/blob/main/ecosystem/06-16-23-basin-halborn-report.pdf"; +const cyfrinAuditLink = + "https://github.com/BeanstalkFarms/Beanstalk-Audits/blob/main/ecosystem/06-16-23-basin-cyfrin-report.pdf"; + +const basinAuditInfo = [ + { + value: "Cyfrin", + imgSrc: CyrfinLogo, + url: cyfrinAuditLink + }, + { + value: "Halborn", + imgSrc: HalbornLogo, + url: halbornAuditLink + }, + { + value: "Code4rena", + imgSrc: Code4renaLogo, + url: code4ArenaAuditLink + } +]; + +const WellDotSol: WellComponentInfo = { + address: WELL_DOT_SOL_ADDRESS, + component: { + name: "Well.sol", + summary: "A standard Well implementation that prioritizes flexibility and composability.", + description: [ + "A standard Well implementation that prioritizes flexibility and composability.", + "Fits many use cases for a Well." + ], + usedBy: 0, + type: { + type: WellComponentType.WellImplementation, + display: "💧 Well Implementation" + }, + url: "https://github.com/BeanstalkFarms/Basin/blob/master/src/Well.sol" + }, + info: [ + { label: "Deployed By", value: "Beanstalk Farms", imgSrc: BeanstalkFarmsLogo }, + { label: "Block Deployed", value: "17977943" }, + { label: "Audited by", value: basinAuditInfo } + ], + links: { + etherscan: `https://etherscan.io/address/${WELL_DOT_SOL_ADDRESS}`, + github: "https://github.com/BeanstalkFarms/Basin/blob/master/src/Well.sol", + learnMore: "https://github.com/BeanstalkFarms/Basin/blob/master/src/Well.sol" + } +}; + +const MultiFlowPump: WellComponentInfo = { + address: MULTI_FLOW_PUMP_ADDRESS, + component: { + name: "Multi Flow", + fullName: "Multi Flow Pump", + summary: "An inter-block MEV manipulation resistant oracle implementation.", + description: [ + "Comprehensive multi-block MEV manipulation-resistant oracle implementation which serves up Well pricing data with an EMA for instantaneous prices and a TWAP for weighted averages over time." + ], + usedBy: 0, + url: "https://docs.basin.exchange/implementations/multi-flow-pump", + type: { + type: WellComponentType.Pump, + display: "🔮 Pump" + } + }, + info: [ + { + label: "Deployed By", + value: "Brendan Sanderson", + imgSrc: BrendanTwitterPFP, + url: "https://github.com/BrendanSanderson" + }, + { label: "Deployed Block", value: "17977942" }, + { label: "Audited by", value: basinAuditInfo } + ], + links: { + etherscan: `https://etherscan.io/address/${MULTI_FLOW_PUMP_ADDRESS}`, + github: "https://github.com/BeanstalkFarms/Basin/blob/master/src/pumps/MultiFlowPump.sol", + learnMore: "https://github.com/BeanstalkFarms/Basin/blob/master/src/pumps/MultiFlowPump.sol" + } +}; + +const ConstantProduct2: WellComponentInfo = { + address: CONSTANT_PRODUCT_2_ADDRESS, + component: { + name: "Constant Product 2", + summary: "A standard x*y = k token pricing function for two tokens.", + description: ["A standard x*y = k token pricing function for two tokens."], + url: "https://github.com/BeanstalkFarms/Basin/blob/master/src/functions/ConstantProduct2.sol", + type: { + type: WellComponentType.WellFunction, + display: "Well Function", + imgSrc: ClockIcon + }, + usedBy: 0 + }, + info: [ + { label: "Deployed By", value: "Beanstalk Farms", imgSrc: BeanstalkFarmsLogo }, + { label: "Deployed Block", value: "17977906" }, + { label: "Audited by", value: basinAuditInfo } + ], + links: { + etherscan: `https://etherscan.io/address/${CONSTANT_PRODUCT_2_ADDRESS}`, + github: + "https://github.com/BeanstalkFarms/Basin/blob/master/src/functions/ConstantProduct2.sol", + learnMore: + "https://github.com/BeanstalkFarms/Basin/blob/master/src/functions/ConstantProduct2.sol" + } +}; + +type WellComponentMap = { + wellImplementations: T; + pumps: T; + wellFunctions: T; +}; + +const ComponentWhiteList: WellComponentMap> = { + wellImplementations: { + [WellDotSol.address]: WellDotSol + }, + pumps: { + [MultiFlowPump.address]: MultiFlowPump + }, + wellFunctions: { + [ConstantProduct2.address]: ConstantProduct2 + } +}; + +export const useWhitelistedWellComponents = () => { + const { data: wells } = useWells(); + const { data: implementations } = useWellImplementations(); + const wellFunctions = useWellFunctions(); + const pumps = usePumps(); + + return useMemo(() => { + // make deep copy of ComponentWhiteList + const map = JSON.parse(JSON.stringify(ComponentWhiteList)) as WellComponentMap< + AddressMap + >; + + const pumpMap = toAddressMap(pumps, { keyLowercase: true }); + const wellFunctionMap = toAddressMap(wellFunctions, { keyLowercase: true }); + + for (const well of wells || []) { + // increase usedBy count for each whitelisted well component + if (implementations) { + const implementation = implementations[well.address.toLowerCase()]; + if (implementation in map.wellImplementations) { + map.wellImplementations[implementation].component.usedBy += 1; + } + } + + well.pumps?.forEach((pump) => { + const pumpAddress = pump.address.toLowerCase(); + if (pumpAddress in pumpMap && pumpAddress in map.pumps) { + map.pumps[pumpAddress].component.usedBy += 1; + } + }); + + if (well.wellFunction) { + const wellFunctionAddress = well.wellFunction.address.toLowerCase(); + if (wellFunctionAddress in wellFunctionMap && wellFunctionAddress in map.wellFunctions) { + map.wellFunctions[wellFunctionAddress].component.usedBy += 1; + } + } + } + + const components: WellComponentMap = { + wellImplementations: Object.values(map.wellImplementations), + pumps: Object.values(map.pumps), + wellFunctions: Object.values(map.wellFunctions) + }; + + return { + components, + lookup: map + }; + }, [implementations, pumps, wellFunctions, wells]); +}; diff --git a/projects/dex-ui/src/components/Dropdown.tsx b/projects/dex-ui/src/components/Dropdown.tsx new file mode 100644 index 0000000000..c2208c3409 --- /dev/null +++ b/projects/dex-ui/src/components/Dropdown.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import styled from "styled-components"; +import { theme } from "src/utils/ui/theme"; +import { Flex } from "./Layout"; +import useElementDimensions from "src/utils/ui/useDimensions"; + +export type DropdownProps = { + open: boolean; + trigger: React.ReactNode; + children: React.ReactNode; + setOpen: (open: boolean) => void; + offset?: number; +}; + +const Dropdown = ({ open, children, trigger, offset, setOpen }: DropdownProps) => { + const [ref, dimensions] = useElementDimensions(); + + return ( + + + e.preventDefault()} + onClick={(e) => e.preventDefault()} + > + {trigger} + + + + e.preventDefault()} + > + <>{children} + + + + ); +}; + +const TriggerContainer = styled(Flex)` + position: relative; +`; + +const StyledSingleSelect = styled(DropdownMenu.CheckboxItem)<{ selected: boolean }>` + display: flex; + box-sizing: border-box; + width: 100%; + outline: none; + cursor: pointer; + padding: ${theme.spacing(1, 2)}; + background: ${(p) => (p.selected ? theme.colors.primaryLight : "white")}; + + :hover { + background-color: ${theme.colors.primaryLight}; + } +`; + +const StyledRoot = styled(DropdownMenu.Root)` + position: "relative"; +`; + +const StyledContent = styled(DropdownMenu.Content)<{ $width: number }>` + display: flex; + flex-direction: column; + box-sizing: border-box; + max-width: ${(p) => `${p.$width}px` || "100%"}; + width: ${(p) => `${p.$width}px` || "100%"}; + min-width: ${(p) => `${p.$width}px` || "100%"}; + background-color: white; + border-radius: 0px; + animation-duration: 400ms; + animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); + will-change: transform, opacity; +`; + +const Namespace = Object.assign(Dropdown, { + SingleSelect: StyledSingleSelect +}); + +export { Namespace as Dropdown }; diff --git a/projects/dex-ui/src/components/Form.tsx b/projects/dex-ui/src/components/Form.tsx new file mode 100644 index 0000000000..b21d80c3da --- /dev/null +++ b/projects/dex-ui/src/components/Form.tsx @@ -0,0 +1,103 @@ +import React, { InputHTMLAttributes, forwardRef } from "react"; +import { theme } from "src/utils/ui/theme"; +import styled from "styled-components"; +import { LinksButtonText, Text } from "src/components/Typography"; +import { Flex } from "./Layout"; +import { SearchIcon } from "./Icons"; +import { Control, Controller, FieldValues, Path } from "react-hook-form"; +import { ToggleSwitch } from "./ToggleSwitch"; + +export const StyledForm = styled.form<{ $width: string }>` + ${(props) => props.$width && `width: ${props.$width};`} +`; + +type IconType = "search"; // add more here later + +export type TextInputFieldProps = InputHTMLAttributes & { + error?: string; + startIcon?: IconType; +}; + +const iconMapping = { + search: +}; + +const StartIcon = React.memo((props: { startIcon: IconType | undefined }) => { + if (!props.startIcon) return null; + return iconMapping[props.startIcon]; +}); + +export const TextInputField = forwardRef( + ({ error, startIcon, ...props }, ref) => { + return ( + + + + + + {error ? ( + + {error} + + ) : null} + + ); + } +); + +const Wrapper = styled.div` + display: flex; + flex-direction: row; + gap: ${theme.spacing(0.5)}; + box-sizing: border-box; + align-items: center; + border: 0.5px solid ${theme.colors.black}; + background: ${theme.colors.white}; + padding: ${theme.spacing(1, 1.5)}; + + input { + ${LinksButtonText} + font-weight: 400; + outline: none; + border: none; + width: 100%; + box-sizing: border-box; + } + + svg { + margin-bottom: ${theme.spacing(0.25)}; + } +`; + +const StyledTextInputField = styled.input` + color: ${theme.colors.black}; + + ::placeholder { + color: ${theme.colors.gray}; + } +`; + +type SwitchFieldProps = { + control: Control; + name: Path; + disabled?: boolean; +}; + +export const SwitchField = ({ + control, + name, + disabled +}: SwitchFieldProps) => { + return ( + { + const value = typeof field.value === "boolean" ? field.value : false; + return ( + field.onChange(!value)} /> + ); + }} + /> + ); +}; diff --git a/projects/dex-ui/src/components/Frame/Frame.tsx b/projects/dex-ui/src/components/Frame/Frame.tsx index 67ad050148..6f3067cc96 100644 --- a/projects/dex-ui/src/components/Frame/Frame.tsx +++ b/projects/dex-ui/src/components/Frame/Frame.tsx @@ -6,52 +6,54 @@ import { Footer } from "./Footer"; import { Window } from "./Window"; import { Settings } from "src/settings"; import CustomToaster from "../TxnToast/CustomToaster"; -// import buildIcon from "src/assets/images/navbar/build.svg"; +import buildIcon from "src/assets/images/navbar/build.svg"; import swapIcon from "src/assets/images/navbar/swap.svg"; import wellsIcon from "src/assets/images/navbar/wells.svg"; import { LinksNav } from "../Typography"; import { BurgerMenuIcon, Discord, Github, Logo, Twitter, X, BeanstalkLogoBlack } from "../Icons"; -import { size } from "src/breakpoints"; -import { useAccount } from "wagmi"; -import { Title } from "../PageComponents/Title"; import { TokenMarquee } from "./TokenMarquee"; import { WalletButton } from "src/components/Wallet"; +import { theme } from "src/utils/ui/theme"; +import { useChainId } from "wagmi"; export const Frame: FC<{}> = ({ children }) => { const isNotProd = !Settings.PRODUCTION; const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const { chain } = useAccount(); + const chain = useChainId(); return ( + {/* Desktop */} - setMobileMenuOpen(false)}> - - -
BASIN
- -
-
- - - - Liquidity - - {/* - Build - */} - - Swap - - {isNotProd && Dev} - - - - - - setMobileMenuOpen(!mobileMenuOpen)}> - {mobileMenuOpen ? : } - + + setMobileMenuOpen(false)}> + + + BASIN + + + + + + + Build + + + Liquidity + + + Swap + + {(isNotProd || false) && Dev} + + + + + + setMobileMenuOpen(!mobileMenuOpen)}> + {mobileMenuOpen ? : } + +
@@ -64,9 +66,9 @@ export const Frame: FC<{}> = ({ children }) => { setMobileMenuOpen(false)}> Wells - {/* setMobileMenuOpen(false)}> + setMobileMenuOpen(false)}> Build - */} + {isNotProd && ( setMobileMenuOpen(false)}> Dev @@ -76,10 +78,18 @@ export const Frame: FC<{}> = ({ children }) => { - + - + @@ -121,35 +131,49 @@ type NavLinkProps = { }; const Container = styled.div` - // border: 1px solid red; display: flex; flex-direction: column; box-sizing: border-box; width: 100vw; height: 100vh; align-items: center; + + ${theme.media.query.sm.only} { + width: 100svw; + height: 100svh; + } `; const NavContainer = styled.nav` border-bottom: 0.5px solid black; display: flex; - flex-direction: row; - justify-content: space-between; width: 100vw; height: 56px; min-height: 56px; box-sizing: border-box; padding: 0px; - align-items: center; - @media (min-width: ${size.mobile}) { + + ${theme.media.query.md.up} { height: 64px; min-height: 64px; } `; +const NavGrid = styled.div` + display: grid; + grid-template-columns: 178px 1fr 192px; + align-items: center; + height: 100%; + width: 100%; + + ${theme.media.query.md.down} { + grid-template-columns: 1fr 1fr; + } +`; + const NavLinks = styled.div` display: none; - @media (min-width: ${size.mobile}) { + ${theme.media.query.md.up} { display: flex; align-self: stretch; align-items: center; @@ -184,12 +208,15 @@ const NavLink = styled(Link)` border-right: 0.5px solid black; } `; -const RightSide = styled.div` - // border: 1px solid red; +const LinksContainer = styled.div` display: flex; + justify-self: center; flex-direction: row; align-self: stretch; - align-items: center; + + ${theme.media.query.md.down} { + display: none; + } `; const BrandContainer = styled.div` @@ -198,6 +225,14 @@ const BrandContainer = styled.div` flex: 1; align-self: stretch; align-items: center; + + ${theme.media.query.md.down} { + justify-self: flex-start; + } +`; + +const BasinText = styled.div` + margin-bottom: -4px; `; const Brand = styled.div` @@ -207,8 +242,8 @@ const Brand = styled.div` a { display: flex; - align-items: center; gap: 4px; + align-items: center; ${LinksNav} text-decoration: none; text-transform: uppercase; @@ -219,20 +254,23 @@ const Brand = styled.div` } } - @media (min-width: ${size.mobile}) { + ${theme.media.query.md.up} { + justify-self: flex-start; padding-left: 48px; } `; const StyledConnectContainer = styled.div` display: none; - @media (min-width: ${size.mobile}) { + ${theme.media.query.md.up} { display: flex; direction: row; width: 192px; align-self: stretch; align-items: center; justify-content: center; + border-left: 0.5px solid black; + box-sizing: border-box; } `; @@ -255,7 +293,9 @@ const DropdownMenu = styled.button<{ open?: boolean }>` flex-direction: column; justify-content: center; gap: 9px; - @media (min-width: ${size.mobile}) { + justify-self: flex-end; + + ${theme.media.query.md.up} { display: none; } div { @@ -285,7 +325,8 @@ const BurgerMenu = styled.div<{ open: boolean }>` margin-left: -0.5px; transform: ${(props) => (props.open ? `translateX(0%)` : `translateX(100%)`)}; z-index: 9999; - @media (min-width: ${size.mobile}) { + + ${theme.media.query.md.up} { display: none; } `; diff --git a/projects/dex-ui/src/components/Frame/TokenMarquee.tsx b/projects/dex-ui/src/components/Frame/TokenMarquee.tsx index 9354052e37..5915e70825 100644 --- a/projects/dex-ui/src/components/Frame/TokenMarquee.tsx +++ b/projects/dex-ui/src/components/Frame/TokenMarquee.tsx @@ -3,25 +3,15 @@ import { size } from "src/breakpoints"; import styled, { keyframes } from "styled-components"; import { images } from "src/assets/images/tokens"; import { Image } from "../Image"; -import { useTokens } from "src/tokens/TokenProvider"; const randomKey = () => Math.random().toString(36).substring(2, 7); -export const TokenMarquee = () => { - const tokens = useTokens(); - - // Get tokens from wells, but if there aren't any (wrong network connected or something), - // just display ETH and BEAN - const symbols = Object.values(tokens).map((token) => token.symbol); - if (symbols.length === 0) { - symbols.push("BEAN", "WETH"); - } - - // Remove ETH, as it would be a dup with WETH - if (symbols[symbols.length - 1] === "ETH") symbols.pop(); +// only use BEAN & WETH for the Marquee. We can add more as the wells become deeper in liquidity. +const marqueeSymbols = ["BEAN", "WETH"]; +export const TokenMarquee = () => { // we need distinct keys for these, so we return a function so the key can be set later - const logos = symbols.map((symbol) => (key: string) => ( + const logos = marqueeSymbols.map((symbol) => (key: string) => ( {`${symbol} )); diff --git a/projects/dex-ui/src/components/Icons.tsx b/projects/dex-ui/src/components/Icons.tsx index 872f3e4e4d..5724147790 100644 --- a/projects/dex-ui/src/components/Icons.tsx +++ b/projects/dex-ui/src/components/Icons.tsx @@ -45,6 +45,17 @@ export const Github = ({ color = "#000", width, height }: SVGProps) => ( ); +export const Etherscan = ({ color = "#000", width, height }: SVGProps) => ( + + + +); + export const BeanstalkLogoBlack = ({ color = "#000", width = 24, height = 24 }: SVGProps) => ( @@ -212,14 +223,57 @@ export const RightArrowCircle = ({ width = 24, height = 24 }: SVGProps) => ( ); export const RightArrow = ({ color = "#000", width = 24, height = 24 }: SVGProps) => ( - - - + + + + ); +export const LeftArrow = ({ color = "#000", width = 24, height = 24 }: SVGProps) => ( + + + + +) + export const BurgerMenuIcon = ({ color = "#000", width = 24, height = 24 }: SVGProps) => ( ); + +export const CheckIcon = ({ color = "#000", width = 16, height = 16 }: SVGProps) => ( + + + +) + +export const CircleFilledCheckIcon = ({ color = "#000", width = 16, height = 16 }: SVGProps) => ( + + + + +); + +export const CircleEmptyIcon = ({ color = "#000", width = 16, height = 16 }: SVGProps) => ( + + + +); + +export const SearchIcon = ({ color = "#000", width = 16, height = 16 }: SVGProps) => ( + + + + +); + +export const XIcon = ({ color = "#000", width = 16, height = 16 }: SVGProps) => ( + + + + + + +) \ No newline at end of file diff --git a/projects/dex-ui/src/components/Layout.tsx b/projects/dex-ui/src/components/Layout.tsx index a2ed6454e4..8853e0491c 100644 --- a/projects/dex-ui/src/components/Layout.tsx +++ b/projects/dex-ui/src/components/Layout.tsx @@ -1,4 +1,10 @@ import { size } from "src/breakpoints"; +import { AdditionalCssBase, BoxModelBase } from "src/utils/ui/styled"; +import { CommonCssProps, CommonCssStyles } from "src/utils/ui/styled/common"; +import { FlexModelProps, FlexBase } from "src/utils/ui/styled/flex-model"; +import { theme } from "src/utils/ui/theme"; + +import { CssProps } from "src/utils/ui/theme/types"; import styled from "styled-components"; export const Item = styled.div<{ stretch?: boolean; right?: boolean; column?: boolean }>` @@ -18,3 +24,48 @@ export const Row = styled.div<{ gap?: number; mobileGap?: string }>` ${({ gap, mobileGap }) => (mobileGap ? `gap: ${mobileGap};` : `gap: ${gap}px;`)} } `; + +export type BoxProps = CommonCssProps & CssProps; + +export const Box = styled.div` + ${BoxModelBase} + ${CommonCssStyles} + ${AdditionalCssBase} +`; + +export type FlexProps = FlexModelProps & CssProps; + +export const Flex = styled.div` + ${FlexBase} + ${BoxModelBase} + ${AdditionalCssBase} +`; + +export const Divider = styled.div<{ $color?: keyof typeof theme.colors }>` + width: 100%; + border-bottom: 1px solid ${(props) => theme.colors[props.$color || "lightGray"]}; +`; + +export type FlexCardProps = FlexProps & { + $borderColor?: keyof typeof theme.colors; + $bgColor?: keyof typeof theme.colors; + $borderWidth?: number; +}; + +export const FlexCard = styled(Flex)< + FlexProps & { + $borderColor?: keyof typeof theme.colors; + $borderWidth?: number; + $bgColor?: keyof typeof theme.colors; + } +>` + border: ${(p) => p.$borderWidth ?? 1}px solid ${(p) => theme.colors[p.$borderColor || "black"]}; + background: ${(p) => theme.colors[p.$bgColor || "white"]}; + ${(p) => { + if (p.$p || p.$px || p.$py || p.$pt || p.$pr || p.$pb || p.$pl) { + return ""; + } else { + return `padding: ${theme.spacing(2, 3)};`; + } + }} +`; diff --git a/projects/dex-ui/src/components/Liquidity/AddLiquidity.tsx b/projects/dex-ui/src/components/Liquidity/AddLiquidity.tsx index 50282aa238..7030c6a68a 100644 --- a/projects/dex-ui/src/components/Liquidity/AddLiquidity.tsx +++ b/projects/dex-ui/src/components/Liquidity/AddLiquidity.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { TokenInput } from "../../components/Swap/TokenInput"; -import { Token, TokenValue } from "@beanstalk/sdk"; +import { ERC20Token, Token, TokenValue } from "@beanstalk/sdk"; import styled from "styled-components"; import { useAccount } from "wagmi"; import { AddLiquidityETH, Well } from "@beanstalk/sdk/Wells"; @@ -11,13 +11,14 @@ import { ensureAllowance, hasMinimumAllowance } from "./allowance"; import { Log } from "../../utils/logger"; import QuoteDetails from "./QuoteDetails"; import { TransactionToast } from "../TxnToast/TransactionToast"; -import { getPrice } from "src/utils/price/usePrice"; import useSdk from "src/utils/sdk/useSdk"; import { useWellReserves } from "src/wells/useWellReserves"; import { Checkbox } from "../Checkbox"; import { size } from "src/breakpoints"; import { LoadingTemplate } from "src/components/LoadingTemplate"; import { ActionWalletButtonWrapper } from "src/components/Wallet"; +import { useTokenPrices } from "src/utils/price/useTokenPrices"; +import { PriceLookups } from "src/utils/price/priceLookups"; type BaseAddLiquidityProps = { slippage: number; @@ -26,7 +27,14 @@ type BaseAddLiquidityProps = { }; type AddLiquidityProps = { + /** + * Well + */ well: Well; + /** + * Well Tokens (Non Nullable) + */ + tokens: ERC20Token[]; } & BaseAddLiquidityProps; export type AddLiquidityQuote = { @@ -36,33 +44,54 @@ export type AddLiquidityQuote = { estimate: TokenValue; }; -const AddLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, handleSlippageValueChange }: AddLiquidityProps) => { +const AddLiquidityContent = ({ + well, + tokens, + slippage, + slippageSettingsClickHandler, + handleSlippageValueChange +}: AddLiquidityProps) => { const { address } = useAccount(); - const [amounts, setAmounts] = useState({}); - const inputs = Object.values(amounts); + const sdk = useSdk(); - const [balancedMode, setBalancedMode] = useState(true); - // Indexed in the same order as well.tokens - const [tokenAllowance, setTokenAllowance] = useState([]); - const [prices, setPrices] = useState<(TokenValue | null)[]>([]); + const WETH = sdk.tokens.WETH; + const token1 = tokens[0]; + const token2 = tokens[1]; + // Local State + const [amounts, setAmounts] = useState({}); + const [balancedMode, setBalancedMode] = useState(true); + const [useWETH, setUseWETH] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); const [hasEnoughBalance, setHasEnoughBalance] = useState(false); - const sdk = useSdk(); + const inputs = Object.values(amounts); + + /// Queries const { reserves: wellReserves, refetch: refetchWellReserves } = useWellReserves(well); + const { data: prices = [] } = useTokenPrices(well, { + refetchInterval: 15 * 1000, + staleTime: 15 * 1000, + refetchOnWindowFocus: "always", + select: (data) => { + return [data[token1.symbol] || null, data[token2.symbol] || null]; + } + }); - const [isSubmitting, setIsSubmitting] = useState(false); + // Indexed in the same order as well.tokens + const [tokenAllowance, setTokenAllowance] = useState([]); - const [useWETH, setUseWETH] = useState(false); + const canFetchPrice1 = !!(token1 && token1.symbol in PriceLookups); + const canFetchPrice2 = !!(token2 && token2.symbol in PriceLookups); + const canFetchPrices = Boolean(canFetchPrice1 && canFetchPrice2 && prices.length === 2); + + const someWellReservesEmpty = Boolean(wellReserves && wellReserves.some((reserve) => reserve.eq(0))); + const areSomeInputsZero = Boolean(inputs.some((amt) => amt.value.eq("0"))); useEffect(() => { - const run = async () => { - if (!well?.tokens) return; - const prices = await Promise.all(well.tokens.map((t) => getPrice(t, sdk))); - setPrices(prices); - }; - run(); - }, [sdk, well?.tokens]); + console.log({ someWellReservesEmpty, areSomeInputsZero }); + + }, [someWellReservesEmpty, areSomeInputsZero]) const atLeastOneAmountNonZero = useMemo(() => { if (!well.tokens || well.tokens.length === 0) return false; @@ -72,31 +101,20 @@ const AddLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, han }, [inputs, well.tokens]); const hasWETH = useMemo(() => { - if (!well.tokens || well.tokens.length === 0) return false; - - let isWETHPair = false; - for (let i = 0; i < well.tokens.length; i++) { - if (well.tokens[i].symbol === "WETH") { - isWETHPair = true; - } - } - return isWETHPair; - }, [well.tokens]); + if (!well.tokens || !well.tokens.length) return false; + return Boolean(well.tokens.some((tk) => tk.symbol === WETH.symbol)); + }, [well.tokens, WETH]); const indexWETH = useMemo(() => { - if (!hasWETH || !well.tokens || well.tokens.length === 0) return null; - - let index = null; - for (let i = 0; i < well.tokens.length; i++) { - if (well.tokens[i].symbol === "WETH") { - return i; - } - } - return index; - }, [hasWETH, well.tokens]); + if (!hasWETH || !well.tokens || !well.tokens.length) return null; + const index = well.tokens.findIndex((tk) => tk.symbol === WETH.symbol); + return index >= 0 ? index : null; + }, [hasWETH, well.tokens, WETH]); - const useNativeETH = !useWETH && indexWETH && inputs[indexWETH] && inputs[indexWETH].gt(TokenValue.ZERO); + const useNativeETH = + !useWETH && indexWETH && inputs[indexWETH] && inputs[indexWETH].gt(TokenValue.ZERO); + // Check Balances useEffect(() => { const checkBalances = async () => { if (!address || !well.tokens) { @@ -128,6 +146,7 @@ const AddLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, han checkBalances(); }, [address, amounts, sdk.tokens.ETH, useWETH, well.tokens]); + // check allowances const checkMinAllowanceForAllTokens = useCallback(async () => { if (!address) { return; @@ -137,7 +156,12 @@ const AddLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, han for (let [index, token] of well.tokens!.entries()) { const targetAddress = useNativeETH ? sdk.addresses.DEPOT.MAINNET : well.address; if (amounts[index]) { - const tokenHasMinAllowance = await hasMinimumAllowance(address, targetAddress, token, amounts[index]); + const tokenHasMinAllowance = await hasMinimumAllowance( + address, + targetAddress, + token, + amounts[index] + ); Log.module("AddLiquidity").debug( `Token ${token.symbol} with amount ${amounts[index].toHuman()} has approval ${tokenHasMinAllowance}` ); @@ -152,50 +176,53 @@ const AddLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, han } } setTokenAllowance(_tokenAllowance); - }, [address, amounts, useNativeETH, well.address, sdk.addresses.DEPOT.MAINNET, hasWETH, useWETH, well.tokens]); + }, [ + address, + amounts, + useNativeETH, + well.address, + sdk.addresses.DEPOT.MAINNET, + hasWETH, + useWETH, + well.tokens + ]); // Once we have our first quote, we show the details. // Subsequent quote invocations shows a spinner in the Expected Output row const [showQuoteDetails, setShowQuoteDetails] = useState(false); const resetAmounts = useCallback(() => { - if (well.tokens) { - const initialAmounts: LiquidityAmounts = {}; - for (let i = 0; i < well.tokens.length; i++) { - initialAmounts[i] = TokenValue.ZERO; - } - - setAmounts(initialAmounts); - } - }, [well.tokens, setAmounts]); + setAmounts({ + 0: token1.amount(0), + 1: token2.amount(0) + }); + }, [token1, token2]); + // reset the amounts from the beginning useEffect(() => { - if (well.tokens) { - const initialAmounts: LiquidityAmounts = {}; - for (let i = 0; i < well.tokens.length; i++) { - initialAmounts[i] = TokenValue.ZERO; - } - - setAmounts(initialAmounts); - } - }, [well.tokens]); + resetAmounts(); + }, [resetAmounts]); - const allTokensHaveMinAllowance = useMemo(() => tokenAllowance.filter((a) => a === false).length === 0, [tokenAllowance]); + const allTokensHaveMinAllowance = useMemo( + () => tokenAllowance.filter((a) => a === false).length === 0, + [tokenAllowance] + ); const { data: quote } = useQuery({ queryKey: ["wells", "quote", "addliquidity", address, amounts, allTokensHaveMinAllowance], queryFn: async () => { - if (!atLeastOneAmountNonZero) { + if ((someWellReservesEmpty && areSomeInputsZero) || !atLeastOneAmountNonZero) { setShowQuoteDetails(false); return null; } - try { let quote; let estimate; let gas; quote = await well.addLiquidityQuote(inputs); + console.log("quote: ", quote.toHuman()); + if (allTokensHaveMinAllowance && tokenAllowance.length) { if (useNativeETH) { const addLiq = new AddLiquidityETH(sdk.wells); @@ -203,16 +230,15 @@ const AddLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, han } else { estimate = await well.addLiquidityGasEstimate(inputs, quote, address); } + } else { estimate = TokenValue.ZERO; } + setShowQuoteDetails(true); + gas = estimate; - return { - quote, - gas, - estimate - }; + return { quote, gas, estimate }; } catch (error: any) { Log.module("addliquidity").error("Error during quote: ", (error as Error).message); return null; @@ -243,9 +269,15 @@ const AddLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, han quote.estimate.mul(1.2) ); } else { - addLiquidityTxn = await well.addLiquidity(inputs, quoteAmountLessSlippage, address, undefined, { - gasLimit: quote.estimate.mul(1.2).toBigNumber() - }); + addLiquidityTxn = await well.addLiquidity( + inputs, + quoteAmountLessSlippage, + address, + undefined, + { + gasLimit: quote.estimate.mul(1.2).toBigNumber() + } + ); } toast.confirming(addLiquidityTxn); const receipt = await addLiquidityTxn.wait(); @@ -260,19 +292,32 @@ const AddLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, han setIsSubmitting(false); } } - }, [quote, address, slippage, well, sdk.wells, inputs, useNativeETH, resetAmounts, checkMinAllowanceForAllTokens, refetchWellReserves]); + }, [ + quote, + address, + slippage, + well, + sdk.wells, + inputs, + useNativeETH, + resetAmounts, + checkMinAllowanceForAllTokens, + refetchWellReserves + ]); const handleImbalancedInputChange = useCallback( (index: number) => (a: TokenValue) => { - setAmounts({ ...amounts, [index]: a }); + const newAmounts = { ...amounts, [index]: a }; + setAmounts(newAmounts); }, [amounts] ); const handleBalancedInputChange = useCallback( (index: number) => (amount: TokenValue) => { - if (!prices[index]) { + if (!canFetchPrices || !prices) { setAmounts({ ...amounts, [index]: amount }); + console.log("inbalanced mode..."); return; } const amountInUSD = amount.mul(prices[index] || TokenValue.ZERO); @@ -288,7 +333,7 @@ const AddLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, han } setAmounts(Object.assign({}, _amounts)); }, - [amounts, prices, well.tokens] + [amounts, canFetchPrices, prices, well.tokens] ); const toggleBalanceMode = useCallback(() => { @@ -339,15 +384,24 @@ const AddLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, han await ensureAllowance(address, targetAddress, well.tokens[tokenIndex], amounts[tokenIndex]); checkMinAllowanceForAllTokens(); }, - [address, well.tokens, amounts, useNativeETH, well.address, sdk.addresses.DEPOT.MAINNET, checkMinAllowanceForAllTokens] + [ + address, + well.tokens, + amounts, + useNativeETH, + well.address, + sdk.addresses.DEPOT.MAINNET, + checkMinAllowanceForAllTokens + ] ); const buttonLabel = useMemo(() => { if (!address) return "Connect Wallet"; if (!hasEnoughBalance) return "Insufficient Balance"; if (!atLeastOneAmountNonZero) return "Enter Amount(s)"; + if (someWellReservesEmpty && areSomeInputsZero) return "Both Amounts Required"; return "Add Liquidity"; - }, [atLeastOneAmountNonZero, hasEnoughBalance, address]); + }, [atLeastOneAmountNonZero, hasEnoughBalance, address, someWellReservesEmpty, areSomeInputsZero]); return (
@@ -359,17 +413,33 @@ const AddLiquidityContent = ({ well, slippage, slippageSettingsClickHandler, han key={`input${index}`} id={`input${index}`} label={`Input amount in ${token.symbol}`} - token={hasWETH && !useWETH && well.tokens![index].symbol === "WETH" ? sdk.tokens.ETH : well.tokens![index]} + token={hasWETH && !useWETH && tokens[index].equals(WETH) ? sdk.tokens.ETH : tokens[index]} amount={amounts[index]} - onAmountChange={balancedMode ? handleBalancedInputChange(index) : handleImbalancedInputChange(index)} + onAmountChange={ + balancedMode && canFetchPrices + ? handleBalancedInputChange(index) + : handleImbalancedInputChange(index) + } canChangeToken={false} loading={false} /> ))}
- toggleBalanceMode()} /> - {hasWETH && setUseWETH(!useWETH)} />} + {canFetchPrices && ( + toggleBalanceMode()} + /> + )} + {hasWETH && ( + setUseWETH(!useWETH)} + /> + )}
{showQuoteDetails && ( 0 && hasEnoughBalance && well.tokens!.map((token: Token, index: number) => { - if (amounts[index] && amounts[index].gt(TokenValue.ZERO) && tokenAllowance[index] === false) { + if ( + amounts[index] && + amounts[index].gt(TokenValue.ZERO) && + tokenAllowance[index] === false + ) { return ( (
); -export const AddLiquidity: React.FC = (props) => { - if (!props.well || props.loading) { +export const AddLiquidity: React.FC< + BaseAddLiquidityProps & { well: Well | undefined; loading: boolean } +> = ({ well, ...props }) => { + if (!well || props.loading || !well.tokens || well.tokens.length < 2) { return ; } - return ; + return ; }; const LargeGapContainer = styled.div` diff --git a/projects/dex-ui/src/components/Liquidity/QuoteDetails.tsx b/projects/dex-ui/src/components/Liquidity/QuoteDetails.tsx index bbdf879b4c..82e85326af 100644 --- a/projects/dex-ui/src/components/Liquidity/QuoteDetails.tsx +++ b/projects/dex-ui/src/components/Liquidity/QuoteDetails.tsx @@ -82,11 +82,11 @@ const QuoteDetails = ({ } if (type === "FORWARD_SWAP") { - return `${quote.estimate.toHuman("short")} ${wellTokens![1].symbol}`; + return `${quote.estimate.toHuman("short")} ${wellTokens?.[1].symbol}`; } if (type === "REVERSE_SWAP") { - return `${quote.estimate.toHuman("short")} ${wellTokens![0].symbol}`; + return `${quote.estimate.toHuman("short")} ${wellTokens?.[0].symbol}`; } if (type === LIQUIDITY_OPERATION_TYPE.REMOVE) { @@ -103,7 +103,7 @@ const QuoteDetails = ({ if (removeLiquidityMode === REMOVE_LIQUIDITY_MODE.Custom) { const _quoteValue = inputs as TokenValue[]; const allTokensValue: string[] = []; - if (!wellTokens!.length || wellTokens!.length !== _quoteValue.length) { + if (!wellTokens?.length || wellTokens.length !== _quoteValue.length) { return null; } wellTokens?.forEach((token, index) => { @@ -114,13 +114,13 @@ const QuoteDetails = ({ if (removeLiquidityMode === REMOVE_LIQUIDITY_MODE.OneToken) { const _quoteValue = quote?.quote as TokenValue; - return `${_quoteValue.toHuman("short")} ${wellTokens![selectedTokenIndex || 0]!.symbol}`; + return `${_quoteValue.toHuman("short")} ${wellTokens?.[selectedTokenIndex || 0]?.symbol}`; } if (removeLiquidityMode === REMOVE_LIQUIDITY_MODE.Balanced) { const _quoteValue = quote?.quote as TokenValue[]; const allTokensValue: string[] = []; - if (!wellTokens!.length || wellTokens!.length !== _quoteValue.length) { + if (!wellTokens?.length || wellTokens.length !== _quoteValue.length) { return null; } wellTokens?.forEach((token, index) => { @@ -139,40 +139,55 @@ const QuoteDetails = ({ let totalUSDValue = TokenValue.ZERO; let valueInUSD; if (removeLiquidityMode === REMOVE_LIQUIDITY_MODE.OneToken) { - valueInUSD = tokenPrices![selectedTokenIndex!]!.mul( - !Array.isArray(quote.quote) ? quote.quote || TokenValue.ZERO : TokenValue.ZERO - ); - totalUSDValue = totalUSDValue.add(valueInUSD); + const price = selectedTokenIndex && tokenPrices?.[selectedTokenIndex] || TokenValue.ZERO; + const quoteAmt = !Array.isArray(quote.quote) ? quote.quote || TokenValue.ZERO : TokenValue.ZERO; + valueInUSD = price.mul(quoteAmt); + totalUSDValue = totalUSDValue.add(valueInUSD || TokenValue.ZERO); } else { for (let i = 0; i < tokenPrices.length; i++) { - valueInUSD = tokenPrices![i]!.mul( - removeLiquidityMode === REMOVE_LIQUIDITY_MODE.Balanced && Array.isArray(quote.quote) - ? quote.quote[i] || TokenValue.ZERO - : inputs![i] || TokenValue.ZERO - ); + const tokenPrice = tokenPrices[i]; + if (!tokenPrice) { + valueInUSD = TokenValue.ZERO; + } else { + valueInUSD = tokenPrice.mul( + removeLiquidityMode === REMOVE_LIQUIDITY_MODE.Balanced && Array.isArray(quote.quote) + ? quote.quote?.[i] || TokenValue.ZERO + : inputs?.[i] || TokenValue.ZERO + ); + } totalUSDValue = totalUSDValue.add(valueInUSD); } } setTokenUSDValue(totalUSDValue); } else if (type === LIQUIDITY_OPERATION_TYPE.ADD) { - let totalReservesUSDValue = TokenValue.ZERO; + let totalReservesUSDValue: TokenValue | null = TokenValue.ZERO; + for (let i = 0; i < tokenPrices.length; i++) { - const reserveValueInUSD = tokenPrices![i]!.mul(tokenReserves[i]!.add(inputs![i] || TokenValue.ZERO)); + const price = tokenPrices[i]; + const reserve = tokenReserves[i]; + if (!totalReservesUSDValue) break; + if (!price) { + totalReservesUSDValue = null; + continue; + } + const reserveValueInUSD = price && reserve ? price.mul(reserve.add(inputs?.[i] || TokenValue.ZERO)) : TokenValue.ZERO; totalReservesUSDValue = totalReservesUSDValue.add(reserveValueInUSD); } + const lpTokenSupply = await wellLpToken?.getTotalSupply(); if (!lpTokenSupply || lpTokenSupply.eq(TokenValue.ZERO)) { - setTokenUSDValue(totalReservesUSDValue); + setTokenUSDValue(totalReservesUSDValue || TokenValue.ZERO); return; } - const lpTokenUSDValue = totalReservesUSDValue.div(lpTokenSupply.add(quote?.quote as TokenValue)); + const newDenominator = lpTokenSupply.add(quote?.quote as TokenValue); + const lpTokenUSDValue = newDenominator.gt(0) ? (totalReservesUSDValue || TokenValue.ZERO).div(newDenominator) : TokenValue.ZERO; const finalUSDValue = !Array.isArray(quote.quote) ? lpTokenUSDValue.mul(quote.quote) : TokenValue.ZERO; setTokenUSDValue(finalUSDValue); } } else if (type === "FORWARD_SWAP") { - setTokenUSDValue(quote!.estimate.mul(tokenPrices![1] || TokenValue.ZERO)); + setTokenUSDValue(quote?.estimate.mul(tokenPrices?.[1] || 0) || TokenValue.ZERO); } else if (type === "REVERSE_SWAP") { - setTokenUSDValue(inputs![1].mul(tokenPrices![1] || TokenValue.ZERO)); + setTokenUSDValue(inputs?.[1].mul(tokenPrices?.[1] || 0) || TokenValue.ZERO); } }; @@ -190,7 +205,7 @@ const QuoteDetails = ({ } const currentData = tokenReserves.map( - (token, index) => tokenReserves[index]?.mul(tokenPrices![index]!) + (token, index) => tokenReserves[index]?.mul(tokenPrices?.[index] || 0) //'reservesUSD': tokenReserves[index]!.mul(tokenPrices![index]!) ); @@ -198,16 +213,16 @@ const QuoteDetails = ({ if (!quote) return TokenValue.ZERO; if (type === LIQUIDITY_OPERATION_TYPE.REMOVE) { if (removeLiquidityMode === REMOVE_LIQUIDITY_MODE.Custom) { - return tokenReserves[index]?.sub(inputs![index] || TokenValue.ZERO).mul(tokenPrices![index]!); + return tokenReserves[index]?.sub(inputs![index] || TokenValue.ZERO).mul(tokenPrices?.[index] || 0); } else if (removeLiquidityMode === REMOVE_LIQUIDITY_MODE.OneToken && !Array.isArray(quote!.quote)) { - return tokenReserves[index]?.sub(index === selectedTokenIndex ? quote!.quote : TokenValue.ZERO).mul(tokenPrices![index]!); + return tokenReserves[index]?.sub(index === selectedTokenIndex ? quote!.quote : TokenValue.ZERO).mul(tokenPrices?.[index] || 0); } else if (removeLiquidityMode === REMOVE_LIQUIDITY_MODE.Balanced && Array.isArray(quote!.quote)) { - return tokenReserves[index]?.sub(quote!.quote[index]).mul(tokenPrices![index]!); + return tokenReserves[index]?.sub(quote!.quote[index]).mul(tokenPrices?.[index] || 0); } else { return TokenValue.ZERO; } } else { - return tokenReserves[index]?.add(inputs![index] || TokenValue.ZERO).mul(tokenPrices![index]!); + return tokenReserves[index]?.add(inputs![index] || TokenValue.ZERO).mul(tokenPrices?.[index] || 0); } }); @@ -251,7 +266,7 @@ const QuoteDetails = ({ USD Value - {`$${tokenUSDValue.toHuman("short")}`} + {`$${tokenUSDValue.lte(0) ? '--' : tokenUSDValue.toHuman("short")}`} {type !== "FORWARD_SWAP" && type !== "REVERSE_SWAP" && ( diff --git a/projects/dex-ui/src/components/Modal.tsx b/projects/dex-ui/src/components/Modal.tsx new file mode 100644 index 0000000000..aaaae35fe3 --- /dev/null +++ b/projects/dex-ui/src/components/Modal.tsx @@ -0,0 +1,188 @@ +import React, { useCallback, useContext } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import styled, { keyframes } from "styled-components"; +import { theme } from "src/utils/ui/theme"; +import { Text, TextProps } from "./Typography"; +import { Divider, Flex } from "./Layout"; +import x from "src/assets/images/x.svg"; +import { ImageButton } from "./ImageButton"; + +type ModalContextProps = { + open: boolean; + allowClose?: boolean; + wide?: boolean; + onOpenChange: (value: boolean) => void; +}; + +export type ModalProps = { + children: React.ReactNode; +} & ModalContextProps; + +type EventPartial = { + preventDefault: () => void; +}; + +const ModalContext = React.createContext(null); + +const useModalContext = () => { + const context = useContext(ModalContext); + if (!context) { + throw new Error("useModalContext must be used within a ModalProvider"); + } + return context; +}; + +const ModalProvider = ({ children, open, allowClose, wide, onOpenChange }: ModalProps) => { + return ( + + {children} + + ); +}; + +export function Modal({ children, open, wide, allowClose = true, onOpenChange }: ModalProps) { + const closeWithCheck = useCallback( + (e: T) => { + if (!allowClose) { + e.preventDefault(); + return; + } + onOpenChange(false); + }, + [allowClose, onOpenChange] + ); + + return ( + + + + + + {children} + + + + + ); +} + +const overlayShow = keyframes` + from { + opacity: 0; + } + to { + opacity: 1; + } +`; + +const contentShow = keyframes` + from { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +`; +const CloseWrapper = styled.div` + justify-self: flex-end; +`; + +const StyledOverlay = styled(Dialog.Overlay)` + background-color: rgba(0 0 0 / 0.5); + position: fixed; + inset: 0; + animation: ${overlayShow} 150ms cubic-bezier(0.16, 1, 0.3, 1); +`; + +const StyledContent = styled(Dialog.Content)` + background-color: ${theme.colors.white}; + border-radius: 0px; + box-shadow: + hsl(206 22% 7% / 35%) 0px 10px 38px -10px, + hsl(206 22% 7% / 20%) 0px 10px 20px -15px; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + animation: ${contentShow} 150ms cubic-bezier(0.16, 1, 0.3, 1); + + &:focus { + outline: none; + } + + ${theme.media.query.sm.only} { + max-width: calc(100vw - 48px); + } +`; + +const ModalTitle = ({ divider = false, ...props }: TextProps & { divider?: boolean }) => { + const { open, allowClose, wide, onOpenChange } = useModalContext(); + + return ( + + + + {allowClose && ( + + onOpenChange(!open)} + /> + + )} + + {divider && ( + + + + )} + + ); +}; + +const ModalContent = ({ children, noTitle }: { children: React.ReactNode; noTitle?: boolean }) => { + const { wide } = useModalContext(); + return ( + + {children} + + ); +}; + +const ModalContentItem = styled(Flex)<{ $wide?: boolean; $noTitle?: boolean }>` + width: 100%; + padding: ${(p) => + p.$wide + ? theme.spacing(p.$noTitle ? 4 : 0, 4, 4, 4) + : theme.spacing(p.$noTitle ? 2 : 0, 2, 2, 2)}; + box-sizing: border-box; + ${theme.media.query.sm.only} { + padding: ${theme.spacing(2, 2, 2, 2)}; + } +`; + +Modal.Content = ModalContent; + +Modal.Title = ModalTitle; diff --git a/projects/dex-ui/src/components/PageComponents/Title.tsx b/projects/dex-ui/src/components/PageComponents/Title.tsx index e4cff5c663..c60de14f8b 100644 --- a/projects/dex-ui/src/components/PageComponents/Title.tsx +++ b/projects/dex-ui/src/components/PageComponents/Title.tsx @@ -1,7 +1,7 @@ import React from "react"; import { FC } from "src/types"; import styled from "styled-components"; -import { BodyL, BodyS, BodyXS, H2 } from "../Typography"; +import { BodyL, BodyS, H2, H3 } from "../Typography"; import { Link } from "react-router-dom"; import { size } from "src/breakpoints"; @@ -39,6 +39,7 @@ type TitleContainerProps = { const Container = styled.div` display: flex; flex-direction: row; + align-items: center; @media (max-width: ${size.mobile}) { justify-content: start; @@ -48,14 +49,15 @@ const Container = styled.div` const TitleContainer = styled.div` display: flex; flex-direction: row; + align-items: center; `; const TitleText = styled.div` - ${BodyL} + ${H3} ${(props) => props.fontWeight && `font-weight: ${props.fontWeight}`}; text-transform: uppercase; @media (max-width: ${size.mobile}) { - ${({ largeOnMobile }) => (largeOnMobile ? `${H2}` : `${BodyS}`)} + ${({ largeOnMobile }) => (largeOnMobile ? `${H3}` : `${BodyS}`)} } `; const ParentText = styled(Link)` diff --git a/projects/dex-ui/src/components/ProgressCircle.tsx b/projects/dex-ui/src/components/ProgressCircle.tsx new file mode 100644 index 0000000000..037ca8fa51 --- /dev/null +++ b/projects/dex-ui/src/components/ProgressCircle.tsx @@ -0,0 +1,135 @@ +import React from "react"; +import styled, { css, keyframes } from "styled-components"; +import { CheckIcon, XIcon } from "./Icons"; +import { theme } from "src/utils/ui/theme"; + +interface ProgressCircleProps { + size: number; // Size of the circle + progress: number; // Current progress (0 to 100) + strokeWidth: number; // Width of the stroke + trackColor: string; // Color of the background circle + strokeColor: string; // Color of the progress stroke +} + +// Keyframes for the spinning animation +const spin = keyframes` + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +`; + +const createOscillatingAnimation = (circumference: number, progress: number) => keyframes` + 50% { + stroke-dashoffset: ${circumference - (progress / 100) * circumference}; + } + 0%, 100% { + stroke-dashoffset: ${circumference}; + } +`; + +// Styled circle for the progress +const Circle = styled.circle<{ + circumference: number; + progress: number; + animate: boolean; +}>` + fill: none; + stroke-width: ${(props) => props.strokeWidth}; + stroke-dasharray: ${(props) => props.circumference}; + animation: ${(props) => + props.animate && + css` + ${createOscillatingAnimation(props.circumference, props.progress)} 3500ms ease-in-out infinite + `}; +`; + +// Styled SVG container with spin animation +const ProgressSVG = styled.svg<{ progress: number; circumference: number }>` + transform: rotate(-90deg); + animation: ${spin} 650ms linear infinite; +`; + +const ProgressCircle = ({ + size, + progress, + strokeWidth, + trackColor, + strokeColor, + animate = false, + status +}: ProgressCircleProps & { + animate?: boolean; + status?: "success" | "error"; +}) => { + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + + if (status) { + return ( + + + + + + {status === "success" && } + {status === "error" && } + + + ); + } + + return ( + + + + + ); +}; + +const StatusContainer = styled.div<{ size: number }>` + width: ${(props) => props.size}px; + height: ${(props) => props.size}px; + position: relative; +`; + +const AbsoluteCenter = styled.div` + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +`; + +export { ProgressCircle }; diff --git a/projects/dex-ui/src/components/Selectable.tsx b/projects/dex-ui/src/components/Selectable.tsx new file mode 100644 index 0000000000..403e4ca6a1 --- /dev/null +++ b/projects/dex-ui/src/components/Selectable.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { theme } from "src/utils/ui/theme"; +import styled from "styled-components"; +import { Flex } from "src/components/Layout"; +import { ChevronDown, CircleEmptyIcon, CircleFilledCheckIcon } from "./Icons"; +import { ImageButton } from "./ImageButton"; +import { useBoolean } from "src/utils/ui/useBoolean"; + + +type SelectnIndicatorIconProps = { + selected: boolean; + size?: number; +}; +export const SelectIndicatorIcon = ({ selected, size = 16 }: SelectnIndicatorIconProps) => { + return ( + + {selected ? ( + + ) : ( + + )} + + ); +}; + +const ExactSize = styled.div<{ size: number }>` + min-width: ${(props) => props.size}px; + min-height: ${(props) => props.size}px; + max-height: ${(props) => props.size}px; + max-width: ${(props) => props.size}px; + width: ${(props) => props.size}px; + height: ${(props) => props.size}px; +`; + +type AccordionSelectCardProps = { + upper: React.ReactNode; + selected: boolean; + below: JSX.Element; + defaultExpanded?: boolean; + onClick: React.MouseEventHandler | undefined; +}; +export const AccordionSelectCard = ({ + selected, + below, + upper, + defaultExpanded = false, + onClick +}: AccordionSelectCardProps) => { + const [expanded, { toggle }] = useBoolean(defaultExpanded); + + return ( + + + + + {upper} + + { + // prevent the card from being clicked + e.preventDefault(); + e.stopPropagation(); + toggle(); + }} + padding={theme.spacing(1)} + alt="" + /> + + {expanded && ( + <> + + {below} + + )} + + ); +}; + +type SelectCardProps = { + selected: boolean; + children: React.ReactNode; + onClick?: React.MouseEventHandler | undefined; +}; +export const SelectCard = ({ selected, children, onClick }: SelectCardProps) => { + return ( + + + + {children} + + + ); +}; + +const SelectWrapper = styled(Flex).attrs({ $gap: 2 })<{ $active: boolean; $clickable?: boolean }>` + // width: 100%; + border: 1px solid ${(props) => (props.$active ? theme.colors.black : theme.colors.lightGray)}; + background: ${(props) => (props.$active ? theme.colors.primaryLight : theme.colors.white)}; + padding: ${theme.spacing(2, 3)}; + cursor: ${(props) => (props.$clickable ? "pointer" : "default")}; +`; + +const Divider = styled.div` + width: 100%; + border-bottom: 1px solid ${theme.colors.lightGray}; +`; diff --git a/projects/dex-ui/src/components/Swap/Button.tsx b/projects/dex-ui/src/components/Swap/Button.tsx index 7d5410bb0c..ca4071dd74 100644 --- a/projects/dex-ui/src/components/Swap/Button.tsx +++ b/projects/dex-ui/src/components/Swap/Button.tsx @@ -25,7 +25,14 @@ export const Button: FC = ({ secondary = false }) => { return ( - + {loading ? : label} ); @@ -47,7 +54,8 @@ const StyledButton = styled.button` }}; height: 48px; border: none; - outline: ${({ secondary, disabled }) => (secondary ? "0.5px solid #9CA3AF" : disabled ? "0.5px solid #D1D5DB" : "0.5px solid #000")}; + outline: ${({ secondary, disabled }) => + secondary ? "0.5px solid #9CA3AF" : disabled ? "0.5px solid #D1D5DB" : "0.5px solid #000"}; outline-offset: -0.5px; color: ${({ secondary }) => (secondary ? "#000" : "#FFF")}; width: ${({ $width }) => $width}; diff --git a/projects/dex-ui/src/components/Swap/SwapRoot.tsx b/projects/dex-ui/src/components/Swap/SwapRoot.tsx index 4469873db8..b7fca158d9 100644 --- a/projects/dex-ui/src/components/Swap/SwapRoot.tsx +++ b/projects/dex-ui/src/components/Swap/SwapRoot.tsx @@ -1,5 +1,5 @@ import { Token, TokenValue } from "@beanstalk/sdk"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useTokens } from "src/tokens/TokenProvider"; import styled from "styled-components"; import { ArrowButton } from "./ArrowButton"; @@ -48,7 +48,7 @@ export const SwapRoot = () => { const [hasEnoughBalance, setHasEnoughBalance] = useState(false); const [quote, setQuote] = useState(); - const builder = useSwapBuilder(); + const [builder, swappableTokens] = useSwapBuilder(); useEffect(() => { setRecipient(account || NULL_ADDRESS); @@ -92,6 +92,12 @@ export const SwapRoot = () => { setOutAmount(prevInAmount); }; + const routeExists = useMemo(() => { + if (!inToken || !outToken) return false; + const route = builder?.router.getRoute(inToken, outToken); + return route ? !!route.length : false; + }, [builder, inToken, outToken]); + const checkBalance = useCallback( async (token: Token, amount: TokenValue): Promise => { // return true here to support doing quotes without having an account connected. @@ -320,7 +326,9 @@ export const SwapRoot = () => { } }; + const getLabel = useCallback(() => { + if (!routeExists) return "No route available"; if (!inAmount && !outAmount) return "Enter Amount"; if (inToken.address === outToken.address) return "Select different output token"; if (inAmount?.eq(TokenValue.ZERO) && outAmount?.eq(TokenValue.ZERO)) return "Enter Amount"; @@ -328,7 +336,7 @@ export const SwapRoot = () => { if (needsApproval) return "Approve"; return "Swap"; - }, [hasEnoughBalance, inAmount, needsApproval, outAmount, inToken, outToken]); + }, [hasEnoughBalance, inAmount, needsApproval, outAmount, inToken, outToken, routeExists]); if (Object.keys(tokens).length === 0) return There are no tokens. Please check you are connected to the right network.; @@ -346,6 +354,7 @@ export const SwapRoot = () => { canChangeToken={true} loading={isLoadingAllBalances} excludeToken={outToken} + tokenOptions={swappableTokens} /> @@ -363,6 +372,7 @@ export const SwapRoot = () => { showBalance={true} loading={isLoadingAllBalances} excludeToken={inToken} + tokenOptions={swappableTokens} /> { /> - {modalOpen && ( - +
Select a token
- +
    @@ -80,9 +100,15 @@ export const TokenPicker: FC = ({ token, excludeToken, editabl
    {token.symbol} - {token.displayName} + + {token.displayName === "UNKNOWN" ? token.name : token.displayName} +
    - {balancesLoading || isFetching ? : {balances?.[token.symbol]?.toHuman()}} + {balancesLoading || isFetching ? ( + + ) : ( + {balances?.[token.symbol]?.toHuman()} + )} ))}
@@ -100,7 +126,11 @@ export const TokenPicker: FC = ({ token, excludeToken, editabl {connectorFor === "output-amount" && ( - + @@ -108,24 +138,34 @@ export const TokenPicker: FC = ({ token, excludeToken, editabl )}
)} - - - -
    - {list.map((token: Token) => ( - selectToken(token)}> - -
    - {token.symbol} - {token.displayName} -
    - {balancesLoading || isFetching ? : {balances?.[token.symbol]?.toHuman("short")}} -
    - ))} -
-
-
-
+ {modalOpen && ( + + + +
    + {list.map((token: Token) => ( + selectToken(token)}> + +
    + {token.symbol} + {token.displayName} +
    + {balancesLoading || isFetching ? ( + + ) : ( + {balances?.[token.symbol]?.toHuman("short")} + )} +
    + ))} +
+
+
+
+ )} ); }; diff --git a/projects/dex-ui/src/components/Swap/useSwapBuilder.tsx b/projects/dex-ui/src/components/Swap/useSwapBuilder.tsx index b9af4cf9cd..48f8f019ea 100644 --- a/projects/dex-ui/src/components/Swap/useSwapBuilder.tsx +++ b/projects/dex-ui/src/components/Swap/useSwapBuilder.tsx @@ -1,3 +1,4 @@ +import { Token } from "@beanstalk/sdk"; import { SwapBuilder } from "@beanstalk/sdk-wells"; import { useEffect, useState } from "react"; import useSdk from "src/utils/sdk/useSdk"; @@ -7,17 +8,31 @@ export const useSwapBuilder = () => { const sdk = useSdk(); const { data: wells } = useWells(); const [builder, setBuilder] = useState(); + const [tokens, setTokens] = useState([]); useEffect(() => { if (!wells) return; - // if (!sdk.signer) return; + const tokenMap: Record = {}; const b = sdk.wells.swapBuilder; for (const well of wells) { + // only include wells with reserves + if (well.reserves?.[0]?.lte(0) || well.reserves?.[1]?.lte(0)) { + continue; + } + b.addWell(well); setBuilder(b); + + for (const token of well?.tokens || []) { + if (!(token.symbol in tokenMap)) { + tokenMap[token.symbol] = token; + } + } } - }, [wells, sdk.wells.swapBuilder, sdk.signer]); + + setTokens([...Object.values(tokenMap), sdk.tokens.ETH]); + }, [wells, sdk.wells.swapBuilder, sdk.signer, sdk.tokens.ETH]); - return builder; + return [builder, tokens] as const; }; diff --git a/projects/dex-ui/src/components/Table.tsx b/projects/dex-ui/src/components/Table.tsx index 730db65b07..2f509fa4d1 100644 --- a/projects/dex-ui/src/components/Table.tsx +++ b/projects/dex-ui/src/components/Table.tsx @@ -1,4 +1,4 @@ -import { size } from "src/breakpoints"; +import { mediaQuery, size } from "src/breakpoints"; import styled from "styled-components"; export const Table = styled.table` @@ -45,3 +45,9 @@ export const THead = styled.thead` } `; export const TBody = styled.tbody``; + +export const ResponsiveTr = styled(Row)` + ${mediaQuery.sm.only} { + height: 66px; + } +`; diff --git a/projects/dex-ui/src/components/ToggleSwitch.tsx b/projects/dex-ui/src/components/ToggleSwitch.tsx new file mode 100644 index 0000000000..74089ab1a6 --- /dev/null +++ b/projects/dex-ui/src/components/ToggleSwitch.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { theme } from "src/utils/ui/theme"; +import styled from "styled-components"; + +// Styled components + +// TODO: add props for size. Currently, we only support 20px x 32px + +const ToggleContainer = styled.div<{ checked?: boolean; disabled?: boolean }>` + position: relative; + width: 32px; + height: 20px; + border-radius: 20px; + border: 0.5px solid ${theme.colors.lightGray}; + background-color: ${theme.colors.white}; + box-sizing: border-box; + cursor: ${(p) => (p.disabled ? "not-allowed" : "pointer")}; +`; + +const ToggleCircle = styled.div<{ checked?: boolean; disabled?: boolean }>` + position: absolute; + top: 2px; + left: ${(props) => (props.checked ? "14px" : "2px")}; + width: 14px; + height: 14px; + border-radius: 50%; + background-color: ${(props) => (props.checked ? theme.colors.black : theme.colors.lightGray)}; + opacity: ${(props) => (props.disabled ? 0.5 : 1)}; + transition: left 200ms background-color 200ms; +`; + +export type ToggleSwitchProps = { + checked: boolean; + disabled?: boolean; + toggle: () => void; +}; +export const ToggleSwitch = ({ disabled, checked, toggle }: ToggleSwitchProps) => { + return ( + {} : toggle} disabled={disabled}> + + + ); +}; diff --git a/projects/dex-ui/src/components/TokenLogo.tsx b/projects/dex-ui/src/components/TokenLogo.tsx index a3c5b2422b..7aeff2c722 100644 --- a/projects/dex-ui/src/components/TokenLogo.tsx +++ b/projects/dex-ui/src/components/TokenLogo.tsx @@ -3,6 +3,7 @@ import React from "react"; import { images } from "src/assets/images/tokens"; import { size } from "src/breakpoints"; import { FC } from "src/types"; +import { useTokenMetadata } from "src/tokens/useTokenMetadata"; import styled from "styled-components"; type Props = { @@ -13,19 +14,32 @@ type Props = { }; export const TokenLogo: FC = ({ size, mobileSize, token, isLP = false }) => { - const symbol = token?.symbol ? token?.symbol : isLP ? "LP" : "DEFAULT"; - let image = images[symbol]; - if (!image) { - image = isLP ? images.LP : images.DEFAULT; - } + const metadata = useTokenMetadata(token?.address); + const img = getImg({ metadata, token, isLP }); return ( - - {`${token?.symbol} + + {`${token?.symbol} ); }; +const getImg = ({ metadata, token, isLP }: { metadata: ReturnType, token?: Token, isLP?: boolean }) => { + if (token?.logo && !token?.logo?.includes("DEFAULT.svg")) { + return token.logo; + }; + if (metadata?.logo && !metadata?.logo?.includes("DEFAULT.svg")) { + return metadata.logo; + }; + + return isLP ? images.LP : images.DEFAULT; +} + type ContainerProps = { width: number; height: number; @@ -43,6 +57,7 @@ const Container = styled.div` img { width: ${(props) => props.width}px; height: ${(props) => props.height}px; + border-radius: 50%; } @media (max-width: ${size.mobile}) { @@ -51,6 +66,7 @@ const Container = styled.div` img { width: ${(props) => props.mobileWidth}px; height: ${(props) => props.mobileHeight}px; + border-radius: 50%; } } `; diff --git a/projects/dex-ui/src/components/Typography/Text.tsx b/projects/dex-ui/src/components/Typography/Text.tsx new file mode 100644 index 0000000000..32d59749a6 --- /dev/null +++ b/projects/dex-ui/src/components/Typography/Text.tsx @@ -0,0 +1,66 @@ +import React, { forwardRef } from "react"; +import type { HTMLAttributes, ElementType, CSSProperties } from "react"; +import { + BoxModelBase, + BoxModelProps, + FlexPropertiesStyle, + FlexPropertiesProps +} from "src/utils/ui/styled"; +import { BlockDisplayStyle, DisplayStyleProps } from "src/utils/ui/styled/common"; +import { + theme, + FontWeight, + FontColor, + FontVariant, + FontSize, + CssProps, + FontSizeStyle, + LineHeightStyle, + FontWeightStyle, + TextAlignStyle, + TextAlign, + FontColorStyle +} from "src/utils/ui/theme"; +import styled from "styled-components"; +import { ResponsiveTextProps } from "./typography-components"; + +export interface TextProps + extends HTMLAttributes, + BoxModelProps, + CssProps, + ResponsiveTextProps, + FlexPropertiesProps, + DisplayStyleProps { + $variant?: FontVariant; + $weight?: FontWeight; + $color?: FontColor; + $size?: FontSize; + $lineHeight?: number | FontSize; + $align?: TextAlign; + $textDecoration?: CSSProperties["textDecoration"]; + as?: ElementType; + className?: string; + $mobileVariant?: FontVariant; + $whitespace?: CSSProperties["whiteSpace"]; +} + +export const Text = forwardRef((props, ref) => { + return ( + + ); +}); + +const TextComponent = styled.div` + ${(props) => theme.font.styles.variant(props.$variant || "s")} + ${FontSizeStyle} + ${LineHeightStyle} + ${FontWeightStyle} + ${TextAlignStyle} + ${FontColorStyle} + ${BoxModelBase} + ${BlockDisplayStyle} + ${FlexPropertiesStyle} + ${(props) => props.$textDecoration && `text-decoration: ${props.$textDecoration};`} + ${(props) => props.$whitespace && `white-space: ${props.$whitespace};`} + ${(props) => (props.$css ? props.$css : "")} +`; diff --git a/projects/dex-ui/src/components/Typography/index.tsx b/projects/dex-ui/src/components/Typography/index.tsx index b8b6bac920..5aee5717fd 100644 --- a/projects/dex-ui/src/components/Typography/index.tsx +++ b/projects/dex-ui/src/components/Typography/index.tsx @@ -1,98 +1,2 @@ -import { size } from "src/breakpoints"; -import styled, { css } from "styled-components"; - -export const H1 = styled.h1` - font-style: normal; - font-weight: 400; - font-size: 48px; - line-height: 56px; -`; -export const H2 = styled.h2` - font-style: normal; - font-weight: 600; - font-size: 24px; - line-height: 32px; -`; -export const BodyXS = css` - font-style: normal; - font-weight: 400; - font-size: 14px; - line-height: 22px; -`; -export const BodyS = css` - font-style: normal; - font-weight: 400; - font-size: 16px; - line-height: 24px; -`; -export const BodyL = css` - font-style: normal; - font-weight: 400; - font-size: 20px; - line-height: 24px; -`; -export const LinksCaps = css` - font-style: normal; - font-weight: 600; - font-size: 16px; - line-height: 24px; - letter-spacing: 0.06em; - text-decoration-line: underline; - text-transform: uppercase; -`; -export const BodyCaps = css` - font-style: normal; - font-weight: 400; - font-size: 16px; - line-height: 24px; - letter-spacing: 0.06em; - text-transform: uppercase; -`; -export const LinksNav = css` - font-style: normal; - font-weight: 600; - font-size: 16px; - line-height: 24px; - letter-spacing: 0.06em; - text-transform: uppercase; -`; -export const LinksButtonText = css` - font-style: normal; - font-weight: 600; - font-size: 16px; - line-height: 24px; - letter-spacing: 0.02em; -`; -export const LinksTextLink = css` - font-style: normal; - font-weight: 600; - font-size: 16px; - line-height: 24px; - /* identical to box height, or 150% */ - - letter-spacing: 0.02em; - text-decoration-line: underline; -`; - -export const PageTitle = styled.h1` - font-style: normal; - font-weight: 400; - font-size: 48px; - line-height: 56px; - margin: 0px; - padding: 0px; - text-transform: uppercase; -`; - -// Helps nudge text to work around the font's -// messed up baseline, when we want the text -// to be vertically centered. -type NudgeProps = { amount: number; mobileAmount?: number }; -export const TextNudge = styled.div` - margin-top: ${({ amount }) => amount}px; - margin-bottom: ${({ amount }) => -1 * amount}px; - @media (max-width: ${size.mobile}) { - margin-top: ${(props) => props.mobileAmount || props.amount}px; - margin-bottom: ${(props) => -1 * (props.mobileAmount || props.amount)}px; - } -`; +export * from "./Text"; +export * from "./typography-components"; diff --git a/projects/dex-ui/src/components/Typography/typography-components.tsx b/projects/dex-ui/src/components/Typography/typography-components.tsx new file mode 100644 index 0000000000..18c8f2ea7c --- /dev/null +++ b/projects/dex-ui/src/components/Typography/typography-components.tsx @@ -0,0 +1,140 @@ +import styled, { css } from "styled-components"; +import { size } from "src/breakpoints"; +import { theme } from "src/utils/ui/theme"; + +export type ResponsiveTextProps = { + $responsive?: boolean; +}; + +const BaseH1 = css` + font-style: normal; + font-weight: 400; + font-size: 48px; + line-height: 56px; +`; +const BaseH2 = css` + font-style: normal; + font-size: 32px; + font-weight: 600; + line-height: 40px; +`; +const BaseH3 = css` + font-style: normal; + font-weight: 600; + font-size: 24px; + line-height: 32px; +`; +const ResponsiveH3 = css` + font-style: normal; + font-weight: 600; + font-size: 20px; + line-height: 24px; +`; +const ResponsiveBodyL = css` + font-style: normal; + font-weight: 400; + font-size: 18px; + line-height: 24px; +`; + +export const H1 = css` + ${BaseH1} + ${(p) => p.$responsive && ` ${theme.media.query.sm.only} { ${BaseH2} } `} +`; + +export const H2 = css` + ${BaseH2}; + ${(p) => p.$responsive && ` ${theme.media.query.sm.only} { ${BaseH3} } `} +`; + +export const H3 = css` + ${BaseH3} + ${(p) => p.$responsive && `${theme.media.query.sm.only} { ${ResponsiveH3} } `} +`; + +// don't change the font-size for BodyS & BodyXS +export const BodyXS = css` + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 22px; +`; +export const BodyS = css` + font-style: normal; + font-weight: 400; + font-size: 16px; + line-height: 24px; +`; + +export const BodyL = css` + font-style: normal; + font-weight: 400; + font-size: 20px; + line-height: 24px; + ${(p) => p.$responsive && ` ${theme.media.query.sm.only} { ${ResponsiveBodyL} }`} +`; +export const LinksCaps = css` + font-style: normal; + font-weight: 600; + font-size: 16px; + line-height: 24px; + letter-spacing: 0.06em; + text-decoration-line: underline; + text-transform: uppercase; +`; +export const BodyCaps = css` + font-style: normal; + font-weight: 400; + font-size: 16px; + line-height: 24px; + letter-spacing: 0.06em; + text-transform: uppercase; +`; +export const LinksNav = css` + font-style: normal; + font-weight: 600; + font-size: 16px; + line-height: 24px; + letter-spacing: 0.06em; + text-transform: uppercase; +`; +export const LinksButtonText = css` + font-style: normal; + font-weight: 600; + font-size: 16px; + line-height: 24px; + letter-spacing: 0.02em; +`; +export const LinksTextLink = css` + font-style: normal; + font-weight: 600; + font-size: 16px; + line-height: 24px; + /* identical to box height, or 150% */ + + letter-spacing: 0.02em; + text-decoration-line: underline; +`; + +export const PageTitle = styled.h1` + font-style: normal; + font-weight: 400; + font-size: 48px; + line-height: 56px; + margin: 0px; + padding: 0px; + text-transform: uppercase; +`; + +// Helps nudge text to work around the font's +// messed up baseline, when we want the text +// to be vertically centered. +type NudgeProps = { amount: number; mobileAmount?: number }; +export const TextNudge = styled.div` + margin-top: ${({ amount }) => amount}px; + margin-bottom: ${({ amount }) => -1 * amount}px; + @media (max-width: ${size.mobile}) { + margin-top: ${(props) => props.mobileAmount || props.amount}px; + margin-bottom: ${(props) => -1 * (props.mobileAmount || props.amount)}px; + } +`; diff --git a/projects/dex-ui/src/components/Wallet.tsx b/projects/dex-ui/src/components/Wallet.tsx index 1c98ab4fc9..992fd6abd8 100644 --- a/projects/dex-ui/src/components/Wallet.tsx +++ b/projects/dex-ui/src/components/Wallet.tsx @@ -1,10 +1,16 @@ import React, { useEffect } from "react"; import styled from "styled-components"; import { ConnectKitButton, useModal as useConnectKitModal } from "connectkit"; -import { Button } from "src/components/Swap/Button"; import { useAccount } from "wagmi"; +import { ButtonPrimary } from "./Button"; -type ActionWalletButtonProps = { children: JSX.Element }; +type ActionWalletButtonProps = { + children: JSX.Element; + /** + * allow the children to be rendered even if the user is not connected + */ + allow?: boolean; +}; export const WalletButton = () => { useUpdateWalletModalStyles(); @@ -14,7 +20,9 @@ export const WalletButton = () => { {({ isConnected, show, truncatedAddress, ensName }) => { return ( <> - {isConnected ? ensName ?? truncatedAddress : "Connect Wallet"} + + {isConnected ? ensName ?? truncatedAddress : "Connect Wallet"} + ); }} @@ -22,14 +30,17 @@ export const WalletButton = () => { ); }; -export const ActionWalletButtonWrapper = ({ children }: ActionWalletButtonProps) => { +export const ActionWalletButtonWrapper = ({ children, allow }: ActionWalletButtonProps) => { const { address } = useAccount(); useUpdateWalletModalStyles(); - return !address ? ( + return !address && !allow ? ( {({ show }) => { - return