diff --git a/.prettierrc b/.prettierrc index 31ba22d84..d9e642ca6 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,4 +2,4 @@ "semi": false, "singleQuote": true, "printWidth": 120 -} +} \ No newline at end of file diff --git a/package.json b/package.json index 0a4d7c43e..5110a4b59 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,8 @@ "workbox-routing": "^6.1.0" }, "resolutions": { - "@walletconnect/ethereum-provider": "1.6.4" + "@walletconnect/ethereum-provider": "1.6.4", + "react-error-overlay": "6.0.9" }, "scripts": { "start:default": "craco start", diff --git a/public/audio/mooooo-send.mp3.asd b/public/audio/mooooo-send.mp3.asd new file mode 100644 index 000000000..3a0b6fb6f Binary files /dev/null and b/public/audio/mooooo-send.mp3.asd differ diff --git a/src/assets/images/wxdai.png b/src/assets/images/wxdai.png new file mode 100644 index 000000000..c37d75f76 Binary files /dev/null and b/src/assets/images/wxdai.png differ diff --git a/src/assets/images/xdai.png b/src/assets/images/xdai.png new file mode 100644 index 000000000..a24b7c0e4 Binary files /dev/null and b/src/assets/images/xdai.png differ diff --git a/src/components/Confetti/index.tsx b/src/components/Confetti/index.tsx index 5510b270b..de5837c7c 100644 --- a/src/components/Confetti/index.tsx +++ b/src/components/Confetti/index.tsx @@ -10,7 +10,7 @@ export default function Confetti({ start, variant }: { start: boolean; variant?: return start && width && height ? ( theme.bg3}; background: radial-gradient(174.47% 188.91% at 1.84% 0%, #ff007a 0%, #2172e5 100%), #edeef2; border: none; diff --git a/src/components/claim/ClaimModal.tsx b/src/components/claim/ClaimModal.tsx index 07289d794..718a93b0b 100644 --- a/src/components/claim/ClaimModal.tsx +++ b/src/components/claim/ClaimModal.tsx @@ -11,8 +11,8 @@ import tokenLogo from '../../assets/images/token-logo.png' import { useActiveWeb3React } from '../../hooks/web3' import { useModalOpen, useToggleSelfClaimModal } from '../../state/application/hooks' import { ApplicationModal } from '../../state/application/reducer' -import { useClaimCallback, useUserClaimData, useUserUnclaimedAmount } from '../../state/claim/hooks' -import { useUserHasSubmittedClaim } from '../../state/transactions/hooks' +import { useClaimCallback, useUserClaimData, useUserUnclaimedAmount } from 'state/claim/hooks' +import { useUserHasSubmittedClaim } from 'state/transactions/hooks' import { CloseIcon, CustomLightSpinner, ExternalLink, TYPE, UniTokenAnimated } from '../../theme' import { ExplorerDataType, getExplorerLink } from '../../utils/getExplorerLink' import { ButtonPrimary } from '../Button' @@ -51,8 +51,9 @@ export default function ClaimModal() { const isOpen = useModalOpen(ApplicationModal.SELF_CLAIM) const toggleClaimModal = useToggleSelfClaimModal() - const { account, chainId } = useActiveWeb3React() + const { chainId } = useActiveWeb3React() + const account = '0x0010B775429d6C92333E363CBd6BF28dDF1A87E6' // used for UI loading states const [attempting, setAttempting] = useState(false) @@ -60,14 +61,15 @@ export default function ClaimModal() { const userClaimData = useUserClaimData(account) // monitor the status of the claim from contracts and txns - const { claimCallback } = useClaimCallback(account) + const { claimCallback } = useClaimCallback(account) // TODO: remove me, hard coded only for testing const unclaimedAmount: CurrencyAmount | undefined = useUserUnclaimedAmount(account) const { claimSubmitted, claimTxn } = useUserHasSubmittedClaim(account ?? undefined) const claimConfirmed = Boolean(claimTxn?.receipt) function onClaim() { + console.log(`Trying to claim!!!`, unclaimedAmount?.toString(), claimConfirmed) setAttempting(true) - claimCallback() + claimCallback([{ index: 3 }]) // reset modal and log error .catch((error) => { setAttempting(false) diff --git a/src/components/swap/styleds.tsx b/src/components/swap/styleds.tsx index 90019347d..734dcf9c6 100644 --- a/src/components/swap/styleds.tsx +++ b/src/components/swap/styleds.tsx @@ -1,7 +1,7 @@ import { loadingOpacityMixin } from 'components/Loader/styled' import { TooltipContainer } from 'components/Tooltip' import { transparentize } from 'polished' -import { ReactNode } from 'react' +import { MouseEventHandler, ReactNode } from 'react' import { AlertTriangle } from 'react-feather' import { Text } from 'rebass' import styled, { css } from 'styled-components/macro' @@ -89,9 +89,10 @@ export const Dots = styled.span` } ` -const SwapCallbackErrorInner = styled.div` +const SwapCallbackErrorInner = styled.div<{ $css?: string }>` background-color: ${({ theme }) => transparentize(0.9, theme.red1)}; border-radius: 1rem; + position: relative; display: flex; align-items: center; font-size: 0.825rem; @@ -105,6 +106,8 @@ const SwapCallbackErrorInner = styled.div` margin: 0; font-weight: 500; } + + ${({ $css }) => $css} ` const SwapCallbackErrorInnerAlertTriangle = styled.div` @@ -118,9 +121,26 @@ const SwapCallbackErrorInnerAlertTriangle = styled.div` height: 48px; ` -export function SwapCallbackError({ error }: { error: ReactNode }) { +const Closer = styled.div` + position: absolute; + right: 0; + top: 0; + padding: 7px 10px; + font-weight: bold; + cursor: pointer; +` + +export type ErrorMessageProps = { + error?: ReactNode + handleClose?: MouseEventHandler + showClose?: boolean + $css?: string +} + +export function SwapCallbackError({ error, handleClose, showClose, ...styleProps }: ErrorMessageProps) { return ( - + + {showClose && X} diff --git a/src/custom/abis/types/VCow.d.ts b/src/custom/abis/types/VCow.d.ts new file mode 100644 index 000000000..b3497a335 --- /dev/null +++ b/src/custom/abis/types/VCow.d.ts @@ -0,0 +1,370 @@ +/* Autogenerated file. Do not edit manually. */ +/* tslint:disable */ +/* eslint-disable */ + +import { + ethers, + EventFilter, + Signer, + BigNumber, + BigNumberish, + PopulatedTransaction, + BaseContract, + ContractTransaction, + PayableOverrides, + CallOverrides, +} from "ethers"; +import { BytesLike } from "@ethersproject/bytes"; +import { Listener, Provider } from "@ethersproject/providers"; +import { FunctionFragment, EventFragment, Result } from "@ethersproject/abi"; +import type { TypedEventFilter, TypedEvent, TypedListener } from "./common"; + +interface VCowInterface extends ethers.utils.Interface { + functions: { + "claim(uint256,uint8,address,uint256,uint256,bytes32[])": FunctionFragment; + "claimMany(uint256[],uint8[],address[],uint256[],uint256[],bytes32[][],uint256[])": FunctionFragment; + "isClaimed(uint256)": FunctionFragment; + "merkleRoot()": FunctionFragment; + "deploymentTimestamp()": FunctionFragment; + "gnoPrice()": FunctionFragment; + "usdcPrice()": FunctionFragment; + "nativeTokenPrice()": FunctionFragment; + }; + + encodeFunctionData( + functionFragment: "claim", + values: [ + BigNumberish, + BigNumberish, + string, + BigNumberish, + BigNumberish, + BytesLike[] + ] + ): string; + encodeFunctionData( + functionFragment: "claimMany", + values: [ + BigNumberish[], + BigNumberish[], + string[], + BigNumberish[], + BigNumberish[], + BytesLike[][], + BigNumberish[] + ] + ): string; + encodeFunctionData( + functionFragment: "isClaimed", + values: [BigNumberish] + ): string; + encodeFunctionData( + functionFragment: "merkleRoot", + values?: undefined + ): string; + encodeFunctionData( + functionFragment: "deploymentTimestamp", + values?: undefined + ): string; + encodeFunctionData(functionFragment: "gnoPrice", values?: undefined): string; + encodeFunctionData(functionFragment: "usdcPrice", values?: undefined): string; + encodeFunctionData( + functionFragment: "nativeTokenPrice", + values?: undefined + ): string; + + decodeFunctionResult(functionFragment: "claim", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "claimMany", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "isClaimed", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "merkleRoot", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "deploymentTimestamp", + data: BytesLike + ): Result; + decodeFunctionResult(functionFragment: "gnoPrice", data: BytesLike): Result; + decodeFunctionResult(functionFragment: "usdcPrice", data: BytesLike): Result; + decodeFunctionResult( + functionFragment: "nativeTokenPrice", + data: BytesLike + ): Result; + + events: { + "Claimed(uint256,uint8,address,uint256,uint256)": EventFragment; + }; + + getEvent(nameOrSignatureOrTopic: "Claimed"): EventFragment; +} + +export type ClaimedEvent = TypedEvent< + [BigNumber, number, string, BigNumber, BigNumber] & { + index: BigNumber; + claimType: number; + claimant: string; + claimableAmount: BigNumber; + claimedAmount: BigNumber; + } +>; + +export class VCow extends BaseContract { + connect(signerOrProvider: Signer | Provider | string): this; + attach(addressOrName: string): this; + deployed(): Promise; + + listeners, EventArgsObject>( + eventFilter?: TypedEventFilter + ): Array>; + off, EventArgsObject>( + eventFilter: TypedEventFilter, + listener: TypedListener + ): this; + on, EventArgsObject>( + eventFilter: TypedEventFilter, + listener: TypedListener + ): this; + once, EventArgsObject>( + eventFilter: TypedEventFilter, + listener: TypedListener + ): this; + removeListener, EventArgsObject>( + eventFilter: TypedEventFilter, + listener: TypedListener + ): this; + removeAllListeners, EventArgsObject>( + eventFilter: TypedEventFilter + ): this; + + listeners(eventName?: string): Array; + off(eventName: string, listener: Listener): this; + on(eventName: string, listener: Listener): this; + once(eventName: string, listener: Listener): this; + removeListener(eventName: string, listener: Listener): this; + removeAllListeners(eventName?: string): this; + + queryFilter, EventArgsObject>( + event: TypedEventFilter, + fromBlockOrBlockhash?: string | number | undefined, + toBlock?: string | number | undefined + ): Promise>>; + + interface: VCowInterface; + + functions: { + claim( + index: BigNumberish, + claimType: BigNumberish, + claimant: string, + claimableAmount: BigNumberish, + claimedAmount: BigNumberish, + merkleProof: BytesLike[], + overrides?: PayableOverrides & { from?: string | Promise } + ): Promise; + + claimMany( + indices: BigNumberish[], + claimTypes: BigNumberish[], + claimants: string[], + claimableAmounts: BigNumberish[], + claimedAmounts: BigNumberish[], + merkleProofs: BytesLike[][], + sentEth: BigNumberish[], + overrides?: PayableOverrides & { from?: string | Promise } + ): Promise; + + isClaimed( + index: BigNumberish, + overrides?: CallOverrides + ): Promise<[boolean]>; + + merkleRoot(overrides?: CallOverrides): Promise<[string]>; + + deploymentTimestamp(overrides?: CallOverrides): Promise<[BigNumber]>; + + gnoPrice(overrides?: CallOverrides): Promise<[BigNumber]>; + + usdcPrice(overrides?: CallOverrides): Promise<[BigNumber]>; + + nativeTokenPrice(overrides?: CallOverrides): Promise<[BigNumber]>; + }; + + claim( + index: BigNumberish, + claimType: BigNumberish, + claimant: string, + claimableAmount: BigNumberish, + claimedAmount: BigNumberish, + merkleProof: BytesLike[], + overrides?: PayableOverrides & { from?: string | Promise } + ): Promise; + + claimMany( + indices: BigNumberish[], + claimTypes: BigNumberish[], + claimants: string[], + claimableAmounts: BigNumberish[], + claimedAmounts: BigNumberish[], + merkleProofs: BytesLike[][], + sentEth: BigNumberish[], + overrides?: PayableOverrides & { from?: string | Promise } + ): Promise; + + isClaimed(index: BigNumberish, overrides?: CallOverrides): Promise; + + merkleRoot(overrides?: CallOverrides): Promise; + + deploymentTimestamp(overrides?: CallOverrides): Promise; + + gnoPrice(overrides?: CallOverrides): Promise; + + usdcPrice(overrides?: CallOverrides): Promise; + + nativeTokenPrice(overrides?: CallOverrides): Promise; + + callStatic: { + claim( + index: BigNumberish, + claimType: BigNumberish, + claimant: string, + claimableAmount: BigNumberish, + claimedAmount: BigNumberish, + merkleProof: BytesLike[], + overrides?: CallOverrides + ): Promise; + + claimMany( + indices: BigNumberish[], + claimTypes: BigNumberish[], + claimants: string[], + claimableAmounts: BigNumberish[], + claimedAmounts: BigNumberish[], + merkleProofs: BytesLike[][], + sentEth: BigNumberish[], + overrides?: CallOverrides + ): Promise; + + isClaimed(index: BigNumberish, overrides?: CallOverrides): Promise; + + merkleRoot(overrides?: CallOverrides): Promise; + + deploymentTimestamp(overrides?: CallOverrides): Promise; + + gnoPrice(overrides?: CallOverrides): Promise; + + usdcPrice(overrides?: CallOverrides): Promise; + + nativeTokenPrice(overrides?: CallOverrides): Promise; + }; + + filters: { + "Claimed(uint256,uint8,address,uint256,uint256)"( + index?: null, + claimType?: null, + claimant?: null, + claimableAmount?: null, + claimedAmount?: null + ): TypedEventFilter< + [BigNumber, number, string, BigNumber, BigNumber], + { + index: BigNumber; + claimType: number; + claimant: string; + claimableAmount: BigNumber; + claimedAmount: BigNumber; + } + >; + + Claimed( + index?: null, + claimType?: null, + claimant?: null, + claimableAmount?: null, + claimedAmount?: null + ): TypedEventFilter< + [BigNumber, number, string, BigNumber, BigNumber], + { + index: BigNumber; + claimType: number; + claimant: string; + claimableAmount: BigNumber; + claimedAmount: BigNumber; + } + >; + }; + + estimateGas: { + claim( + index: BigNumberish, + claimType: BigNumberish, + claimant: string, + claimableAmount: BigNumberish, + claimedAmount: BigNumberish, + merkleProof: BytesLike[], + overrides?: PayableOverrides & { from?: string | Promise } + ): Promise; + + claimMany( + indices: BigNumberish[], + claimTypes: BigNumberish[], + claimants: string[], + claimableAmounts: BigNumberish[], + claimedAmounts: BigNumberish[], + merkleProofs: BytesLike[][], + sentEth: BigNumberish[], + overrides?: PayableOverrides & { from?: string | Promise } + ): Promise; + + isClaimed( + index: BigNumberish, + overrides?: CallOverrides + ): Promise; + + merkleRoot(overrides?: CallOverrides): Promise; + + deploymentTimestamp(overrides?: CallOverrides): Promise; + + gnoPrice(overrides?: CallOverrides): Promise; + + usdcPrice(overrides?: CallOverrides): Promise; + + nativeTokenPrice(overrides?: CallOverrides): Promise; + }; + + populateTransaction: { + claim( + index: BigNumberish, + claimType: BigNumberish, + claimant: string, + claimableAmount: BigNumberish, + claimedAmount: BigNumberish, + merkleProof: BytesLike[], + overrides?: PayableOverrides & { from?: string | Promise } + ): Promise; + + claimMany( + indices: BigNumberish[], + claimTypes: BigNumberish[], + claimants: string[], + claimableAmounts: BigNumberish[], + claimedAmounts: BigNumberish[], + merkleProofs: BytesLike[][], + sentEth: BigNumberish[], + overrides?: PayableOverrides & { from?: string | Promise } + ): Promise; + + isClaimed( + index: BigNumberish, + overrides?: CallOverrides + ): Promise; + + merkleRoot(overrides?: CallOverrides): Promise; + + deploymentTimestamp( + overrides?: CallOverrides + ): Promise; + + gnoPrice(overrides?: CallOverrides): Promise; + + usdcPrice(overrides?: CallOverrides): Promise; + + nativeTokenPrice(overrides?: CallOverrides): Promise; + }; +} diff --git a/src/custom/abis/types/factories/VCow__factory.ts b/src/custom/abis/types/factories/VCow__factory.ts new file mode 100644 index 000000000..4d036d367 --- /dev/null +++ b/src/custom/abis/types/factories/VCow__factory.ts @@ -0,0 +1,222 @@ +/* Autogenerated file. Do not edit manually. */ +/* tslint:disable */ +/* eslint-disable */ + +import { Contract, Signer, utils } from "ethers"; +import { Provider } from "@ethersproject/providers"; +import type { VCow, VCowInterface } from "../VCow"; + +const _abi = [ + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "uint256", + name: "index", + type: "uint256", + }, + { + indexed: false, + internalType: "enum ClaimingInterface.ClaimType", + name: "claimType", + type: "uint8", + }, + { + indexed: false, + internalType: "address", + name: "claimant", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "claimableAmount", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "claimedAmount", + type: "uint256", + }, + ], + name: "Claimed", + type: "event", + }, + { + inputs: [ + { + internalType: "uint256", + name: "index", + type: "uint256", + }, + { + internalType: "enum ClaimingInterface.ClaimType", + name: "claimType", + type: "uint8", + }, + { + internalType: "address", + name: "claimant", + type: "address", + }, + { + internalType: "uint256", + name: "claimableAmount", + type: "uint256", + }, + { + internalType: "uint256", + name: "claimedAmount", + type: "uint256", + }, + { + internalType: "bytes32[]", + name: "merkleProof", + type: "bytes32[]", + }, + ], + name: "claim", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256[]", + name: "indices", + type: "uint256[]", + }, + { + internalType: "enum ClaimingInterface.ClaimType[]", + name: "claimTypes", + type: "uint8[]", + }, + { + internalType: "address[]", + name: "claimants", + type: "address[]", + }, + { + internalType: "uint256[]", + name: "claimableAmounts", + type: "uint256[]", + }, + { + internalType: "uint256[]", + name: "claimedAmounts", + type: "uint256[]", + }, + { + internalType: "bytes32[][]", + name: "merkleProofs", + type: "bytes32[][]", + }, + { + internalType: "uint256[]", + name: "sentEth", + type: "uint256[]", + }, + ], + name: "claimMany", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "index", + type: "uint256", + }, + ], + name: "isClaimed", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "merkleRoot", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "deploymentTimestamp", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "gnoPrice", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "usdcPrice", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "nativeTokenPrice", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, +]; + +export class VCow__factory { + static readonly abi = _abi; + static createInterface(): VCowInterface { + return new utils.Interface(_abi) as VCowInterface; + } + static connect(address: string, signerOrProvider: Signer | Provider): VCow { + return new Contract(address, _abi, signerOrProvider) as VCow; + } +} diff --git a/src/custom/abis/types/index.ts b/src/custom/abis/types/index.ts index 0d2194a41..8c555de40 100644 --- a/src/custom/abis/types/index.ts +++ b/src/custom/abis/types/index.ts @@ -2,7 +2,9 @@ /* tslint:disable */ /* eslint-disable */ export type { GPv2Settlement } from './GPv2Settlement' +export type { VCow } from './VCow' export { GPv2Settlement__factory } from './factories/GPv2Settlement__factory' +export { VCow__factory } from './factories/VCow__factory' export * from '@src/abis/types' diff --git a/src/custom/abis/vCow.json b/src/custom/abis/vCow.json new file mode 100644 index 000000000..4bb303d04 --- /dev/null +++ b/src/custom/abis/vCow.json @@ -0,0 +1,204 @@ +[ + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "index", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "enum ClaimingInterface.ClaimType", + "name": "claimType", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "address", + "name": "claimant", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "claimableAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "claimedAmount", + "type": "uint256" + } + ], + "name": "Claimed", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + }, + { + "internalType": "enum ClaimingInterface.ClaimType", + "name": "claimType", + "type": "uint8" + }, + { + "internalType": "address", + "name": "claimant", + "type": "address" + }, + { + "internalType": "uint256", + "name": "claimableAmount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "claimedAmount", + "type": "uint256" + }, + { + "internalType": "bytes32[]", + "name": "merkleProof", + "type": "bytes32[]" + } + ], + "name": "claim", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256[]", + "name": "indices", + "type": "uint256[]" + }, + { + "internalType": "enum ClaimingInterface.ClaimType[]", + "name": "claimTypes", + "type": "uint8[]" + }, + { + "internalType": "address[]", + "name": "claimants", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "claimableAmounts", + "type": "uint256[]" + }, + { + "internalType": "uint256[]", + "name": "claimedAmounts", + "type": "uint256[]" + }, + { + "internalType": "bytes32[][]", + "name": "merkleProofs", + "type": "bytes32[][]" + }, + { + "internalType": "uint256[]", + "name": "sentEth", + "type": "uint256[]" + } + ], + "name": "claimMany", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "isClaimed", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "merkleRoot", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "deploymentTimestamp", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "gnoPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "usdcPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "nativeTokenPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/src/custom/assets/cow-swap/CowError.png b/src/custom/assets/cow-swap/CowError.png index ef47e71a6..fa7ec2e90 100644 Binary files a/src/custom/assets/cow-swap/CowError.png and b/src/custom/assets/cow-swap/CowError.png differ diff --git a/src/custom/assets/cow-swap/attention.svg b/src/custom/assets/cow-swap/attention.svg index 415f410d8..014ae198f 100644 --- a/src/custom/assets/cow-swap/attention.svg +++ b/src/custom/assets/cow-swap/attention.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/custom/assets/cow-swap/check.svg b/src/custom/assets/cow-swap/check.svg new file mode 100644 index 000000000..28b9faef6 --- /dev/null +++ b/src/custom/assets/cow-swap/check.svg @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/src/custom/assets/cow-swap/cow-404.png b/src/custom/assets/cow-swap/cow-404.png index 3b18c1176..93a5d57a6 100644 Binary files a/src/custom/assets/cow-swap/cow-404.png and b/src/custom/assets/cow-swap/cow-404.png differ diff --git a/src/custom/assets/cow-swap/cow-load.gif b/src/custom/assets/cow-swap/cow-load.gif index 6403c5982..155d83183 100644 Binary files a/src/custom/assets/cow-swap/cow-load.gif and b/src/custom/assets/cow-swap/cow-load.gif differ diff --git a/src/custom/assets/cow-swap/cowprotocol.svg b/src/custom/assets/cow-swap/cowprotocol.svg new file mode 100644 index 000000000..823fe2893 --- /dev/null +++ b/src/custom/assets/cow-swap/cowprotocol.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/custom/assets/cow-swap/cows-side-by-side.png b/src/custom/assets/cow-swap/cows-side-by-side.png index e14aefee8..e31bfb386 100644 Binary files a/src/custom/assets/cow-swap/cows-side-by-side.png and b/src/custom/assets/cow-swap/cows-side-by-side.png differ diff --git a/src/custom/assets/cow-swap/feedback.svg b/src/custom/assets/cow-swap/feedback.svg index ad7b84a8a..f38268652 100644 --- a/src/custom/assets/cow-swap/feedback.svg +++ b/src/custom/assets/cow-swap/feedback.svg @@ -1,3 +1 @@ - + \ No newline at end of file diff --git a/src/custom/assets/cow-swap/gno.png b/src/custom/assets/cow-swap/gno.png new file mode 100644 index 000000000..f6e1924f7 Binary files /dev/null and b/src/custom/assets/cow-swap/gno.png differ diff --git a/src/custom/assets/cow-swap/network-gnosis-chain-logo.svg b/src/custom/assets/cow-swap/network-gnosis-chain-logo.svg index 36d7d8532..bf49d1310 100644 --- a/src/custom/assets/cow-swap/network-gnosis-chain-logo.svg +++ b/src/custom/assets/cow-swap/network-gnosis-chain-logo.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/src/custom/assets/cow-swap/network-mainnet-logo.svg b/src/custom/assets/cow-swap/network-mainnet-logo.svg index 1834dcc1f..507f41089 100644 --- a/src/custom/assets/cow-swap/network-mainnet-logo.svg +++ b/src/custom/assets/cow-swap/network-mainnet-logo.svg @@ -1,9 +1 @@ - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/src/custom/assets/cow-swap/network-rinkeby-logo.svg b/src/custom/assets/cow-swap/network-rinkeby-logo.svg index 116b1ccf0..f2c9b10e8 100644 --- a/src/custom/assets/cow-swap/network-rinkeby-logo.svg +++ b/src/custom/assets/cow-swap/network-rinkeby-logo.svg @@ -1,9 +1 @@ - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/src/custom/assets/cow-swap/ninja-cow.png b/src/custom/assets/cow-swap/ninja-cow.png index c440dad4b..f8d740ed3 100644 Binary files a/src/custom/assets/cow-swap/ninja-cow.png and b/src/custom/assets/cow-swap/ninja-cow.png differ diff --git a/src/custom/assets/cow-swap/order-check.svg b/src/custom/assets/cow-swap/order-check.svg index e509ff46c..b4857922f 100644 --- a/src/custom/assets/cow-swap/order-check.svg +++ b/src/custom/assets/cow-swap/order-check.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/src/custom/assets/cow-swap/order-cross.svg b/src/custom/assets/cow-swap/order-cross.svg index 1f5c556c4..ea48e4f1d 100644 --- a/src/custom/assets/cow-swap/order-cross.svg +++ b/src/custom/assets/cow-swap/order-cross.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/src/custom/assets/cow-swap/order-open.svg b/src/custom/assets/cow-swap/order-open.svg index 59c71048b..b986a9302 100644 --- a/src/custom/assets/cow-swap/order-open.svg +++ b/src/custom/assets/cow-swap/order-open.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/src/custom/assets/cow-swap/order-presignature-pending.svg b/src/custom/assets/cow-swap/order-presignature-pending.svg index eea538963..cc90f5ae4 100644 --- a/src/custom/assets/cow-swap/order-presignature-pending.svg +++ b/src/custom/assets/cow-swap/order-presignature-pending.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/src/custom/assets/cow-swap/transaction-arrows.svg b/src/custom/assets/cow-swap/transaction-arrows.svg index 808f05d8b..ff78a4f56 100644 --- a/src/custom/assets/cow-swap/transaction-arrows.svg +++ b/src/custom/assets/cow-swap/transaction-arrows.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/src/custom/assets/cow-swap/transaction-confirmed.svg b/src/custom/assets/cow-swap/transaction-confirmed.svg index 916c6f0e9..a18295260 100644 --- a/src/custom/assets/cow-swap/transaction-confirmed.svg +++ b/src/custom/assets/cow-swap/transaction-confirmed.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/src/custom/assets/cow-swap/usdc.png b/src/custom/assets/cow-swap/usdc.png new file mode 100644 index 000000000..45a702084 Binary files /dev/null and b/src/custom/assets/cow-swap/usdc.png differ diff --git a/src/custom/assets/cow-swap/xdai.png b/src/custom/assets/cow-swap/xdai.png new file mode 100644 index 000000000..3050b449f Binary files /dev/null and b/src/custom/assets/cow-swap/xdai.png differ diff --git a/src/custom/components/AddToMetamask/index.tsx b/src/custom/components/AddToMetamask/index.tsx new file mode 100644 index 000000000..cf2abdd72 --- /dev/null +++ b/src/custom/components/AddToMetamask/index.tsx @@ -0,0 +1,85 @@ +import React, { useContext } from 'react' +import { Currency } from '@uniswap/sdk-core' +import styled, { ThemeContext } from 'styled-components/macro' + +import { useActiveWeb3React } from 'hooks/web3' +import useAddTokenToMetamask from 'hooks/useAddTokenToMetamask' +import { CheckCircle } from 'react-feather' +import { RowFixed } from 'components/Row' +import MetaMaskLogo from 'assets/images/metamask.png' + +export type AddToMetamaskProps = { + currency: Currency | undefined +} + +const ButtonCustom = styled.button` + display: flex; + flex: 1 1 auto; + align-self: center; + justify-content: center; + align-items: center; + border-radius: 16px; + min-height: 52px; + border: 1px solid ${({ theme }) => theme.border2}; + color: ${({ theme }) => theme.text1}; + background: transparent; + outline: 0; + padding: 8px 16px; + margin: 16px 0 0; + font-size: 14px; + line-height: 1; + font-weight: 500; + transition: background 0.2s ease-in-out; + cursor: pointer; + + &:hover { + background: ${({ theme }) => theme.border2}; + } + + > a { + display: flex; + align-items: center; + color: inherit; + text-decoration: none; + } +` + +const StyledIcon = styled.img` + height: auto; + width: 20px; + max-height: 100%; + margin: 0 10px 0 0; +` + +const CheckCircleCustom = styled(CheckCircle)` + height: auto; + width: 20px; + max-height: 100%; + margin: 0 10px 0 0; +` + +export default function AddToMetamask(props: AddToMetamaskProps) { + const { currency } = props + const theme = useContext(ThemeContext) + const { library } = useActiveWeb3React() + const { addToken, success } = useAddTokenToMetamask(currency) + + if (!currency || !library?.provider?.isMetaMask) { + return null + } + + return ( + + {!success ? ( + + Add {currency.symbol} to Metamask + + ) : ( + + + Added {currency.symbol}{' '} + + )} + + ) +} diff --git a/src/custom/components/CowClaimButton/index.tsx b/src/custom/components/CowClaimButton/index.tsx new file mode 100644 index 000000000..5c8120e1b --- /dev/null +++ b/src/custom/components/CowClaimButton/index.tsx @@ -0,0 +1,100 @@ +import { Trans } from '@lingui/macro' +import { Dots } from 'components/swap/styleds' +import styled, { css } from 'styled-components/macro' +import CowProtocolLogo from 'components/CowProtocolLogo' +import { useUserHasSubmittedClaim } from 'state/transactions/hooks' + +export const Wrapper = styled.div<{ isClaimPage?: boolean | null }>` + ${({ theme }) => theme.card.boxShadow}; + color: ${({ theme }) => theme.text1}; + padding: 0 12px; + font-size: 15px; + font-weight: 500; + height: 38px; + display: flex; + align-items: center; + position: relative; + border-radius: 12px; + pointer-events: auto; + + > b { + margin: 0 0 0 5px; + color: inherit; + font-weight: inherit; + white-space: nowrap; + + &::before, + &::after { + content: ''; + position: absolute; + left: -1px; + top: -1px; + background: ${({ theme }) => + `linear-gradient(45deg, ${theme.primary1}, ${theme.primary2}, ${theme.primary3}, ${theme.bg4}, ${theme.primary1}, ${theme.primary2})`}; + background-size: 800%; + width: calc(100% + 2px); + height: calc(100% + 2px); + z-index: -1; + animation: glow 50s linear infinite; + transition: background-position 0.3s ease-in-out; + border-radius: 12px; + } + + &::after { + filter: blur(8px); + } + + &:hover::before, + &:hover::after { + animation: glow 12s linear infinite; + } + + // Stop glowing effect on claim page + ${({ isClaimPage }) => + isClaimPage && + css` + &::before, + &::after { + content: none; + } + `}; + + @keyframes glow { + 0% { + background-position: 0 0; + } + 50% { + background-position: 300% 0; + } + 100% { + background-position: 0 0; + } + } +` + +interface CowClaimButtonProps { + isClaimPage?: boolean | null | undefined + account?: string | null | undefined + handleOnClickClaim?: () => void +} + +export default function CowClaimButton({ isClaimPage, account, handleOnClickClaim }: CowClaimButtonProps) { + const { claimTxn } = useUserHasSubmittedClaim(account ?? undefined) + + return ( + + {claimTxn && !claimTxn?.receipt ? ( + + Claiming vCOW... + + ) : ( + <> + + + vCOW + + + )} + + ) +} diff --git a/src/custom/components/CowProtocolLogo/index.tsx b/src/custom/components/CowProtocolLogo/index.tsx new file mode 100644 index 000000000..0d8fd2861 --- /dev/null +++ b/src/custom/components/CowProtocolLogo/index.tsx @@ -0,0 +1,42 @@ +import styled from 'styled-components/macro' +import CowProtocolIcon from 'assets/cow-swap/cowprotocol.svg' + +export const Icon = styled.span` + --defaultSize: 24px; + --smallSize: ${({ size }) => (size ? `calc(${size}px / 2)` : 'calc(var(--defaultSize) / 2)')}; + ${({ theme }) => theme.cowToken.background}; + ${({ theme }) => theme.cowToken.boxShadow}; + height: ${({ size }) => (size ? `${size}px` : 'var(--defaultSize)')}; + width: ${({ size }) => (size ? `${size}px` : 'var(--defaultSize)')}; + display: inline-block; + margin: 0; + border-radius: ${({ size }) => (size ? `${size}px` : 'var(--defaultSize)')}; + position: relative; + + ${({ theme }) => theme.mediaWidth.upToSmall` + width: var(--smallSize); + height: var(--smallSize); + border-radius: var(--smallSize); + `}; + + &::after { + content: ''; + width: 100%; + height: 100%; + position: absolute; + left: 0; + right: 0; + top: 8%; + bottom: 0; + margin: auto; + background: url(${CowProtocolIcon}) no-repeat center/70%; + } +` + +interface Props { + size?: number | undefined +} + +export default function CowProtocolLogo({ size }: Props) { + return +} diff --git a/src/custom/components/CurrencyInputPanel/CurrencyInputPanelMod.tsx b/src/custom/components/CurrencyInputPanel/CurrencyInputPanelMod.tsx index e5cbd3ded..4d033274a 100644 --- a/src/custom/components/CurrencyInputPanel/CurrencyInputPanelMod.tsx +++ b/src/custom/components/CurrencyInputPanel/CurrencyInputPanelMod.tsx @@ -152,7 +152,7 @@ export const StyledBalanceMax = styled.button<{ disabled?: boolean }>` `}; ` -const StyledNumericalInput = styled(NumericalInput)<{ $loading: boolean }>` +export const StyledNumericalInput = styled(NumericalInput)<{ $loading: boolean }>` ${loadingOpacityMixin} ` diff --git a/src/custom/components/CurrencyLogo/CurrencyLogoMod.tsx b/src/custom/components/CurrencyLogo/CurrencyLogoMod.tsx index 680aca618..1c6fd8b03 100644 --- a/src/custom/components/CurrencyLogo/CurrencyLogoMod.tsx +++ b/src/custom/components/CurrencyLogo/CurrencyLogoMod.tsx @@ -4,6 +4,7 @@ import { useMemo } from 'react' import styled from 'styled-components/macro' import EthereumLogo from 'assets/images/ethereum-logo.png' +import xDaiLogo from 'assets/images/xdai.png' import useHttpLocations from 'hooks/useHttpLocations' import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo' import Logo from 'components/Logo' @@ -85,7 +86,11 @@ export default function CurrencyLogo({ }, [currency, uriLocations]) if (currency?.isNative) { - return + return chainIdToNetworkName(currency.chainId) === 'ethereum' ? ( + + ) : ( + + ) } return diff --git a/src/custom/components/EnhancedTransactionLink/index.tsx b/src/custom/components/EnhancedTransactionLink/index.tsx new file mode 100644 index 000000000..0f8d8133c --- /dev/null +++ b/src/custom/components/EnhancedTransactionLink/index.tsx @@ -0,0 +1,32 @@ +import { ExplorerDataType } from 'utils/getExplorerLink' + +import { ExplorerLink } from 'components/ExplorerLink' +import { GnosisSafeLink } from 'components/AccountDetails/Transaction/StatusDetails' + +import { EnhancedTransactionDetails, HashType } from 'state/enhancedTransactions/reducer' +import { useWalletInfo } from 'hooks/useWalletInfo' + +interface Props { + tx: EnhancedTransactionDetails +} + +/** + * Creates a link to the relevant explorer: Etherscan, GP Explorer or Blockscout, or Gnosis Safe web if its a Gnosis Safe Transaction + * @param props + */ +export function EnhancedTransactionLink(props: Props) { + const { tx } = props + const { chainId, gnosisSafeInfo } = useWalletInfo() + + if (tx.hashType === HashType.GNOSIS_SAFE_TX) { + const safeTx = tx.safeTransaction + + if (!chainId || !safeTx || !gnosisSafeInfo) { + return null + } + + return + } else { + return + } +} diff --git a/src/custom/components/Header/HeaderMod.tsx b/src/custom/components/Header/HeaderMod.tsx index f02be542f..845d30ed2 100644 --- a/src/custom/components/Header/HeaderMod.tsx +++ b/src/custom/components/Header/HeaderMod.tsx @@ -125,8 +125,7 @@ export const AccountElement = styled.div<{ active: boolean }>` } ` -/* -const UNIAmount = styled(AccountElement)` +export const UNIAmount = styled(AccountElement)` color: white; padding: 4px 8px; height: 36px; @@ -135,7 +134,7 @@ const UNIAmount = styled(AccountElement)` background: radial-gradient(174.47% 188.91% at 1.84% 0%, #ff007a 0%, #2172e5 100%), #edeef2; ` -const UNIWrapper = styled.span` +export const UNIWrapper = styled.span` width: fit-content; position: relative; cursor: pointer; @@ -148,7 +147,6 @@ const UNIWrapper = styled.span` opacity: 0.9; } ` -*/ export const HideSmall = styled.span` ${({ theme }) => theme.mediaWidth.upToSmall` diff --git a/src/custom/components/Header/index.tsx b/src/custom/components/Header/index.tsx index 773831571..e60b96e85 100644 --- a/src/custom/components/Header/index.tsx +++ b/src/custom/components/Header/index.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { SupportedChainId as ChainId } from 'constants/chains' import { ExternalLink } from 'theme' +import { useHistory, useLocation } from 'react-router-dom' import HeaderMod, { Title, @@ -14,6 +15,7 @@ import HeaderMod, { StyledNavLink as StyledNavLinkUni, StyledMenuButton, HeaderFrame, + UNIWrapper, } from './HeaderMod' import Menu from 'components/Menu' import { Moon, Sun } from 'react-feather' @@ -26,13 +28,24 @@ import { darken } from 'polished' import TwitterImage from 'assets/cow-swap/twitter.svg' import OrdersPanel from 'components/OrdersPanel' import { ApplicationModal } from 'state/application/reducer' -import { useModalOpen } from 'state/application/hooks' import { supportedChainId } from 'utils/supportedChainId' import { formatSmart } from 'utils/format' import Web3Status from 'components/Web3Status' import NetworkSelector from 'components/Header/NetworkSelector' import SVG from 'react-inlinesvg' +import { + useModalOpen, + /*useShowClaimPopup,*/ + // useToggleSelfClaimModal +} from 'state/application/hooks' +//import { useUserHasAvailableClaim } from 'state/claim/hooks' + +import Modal from 'components/Modal' +// import ClaimModal from 'components/claim/ClaimModal' +import UniBalanceContent from 'components/Header/UniBalanceContent' +import CowClaimButton from 'components/CowClaimButton' +import { IS_CLAIMING_ENABLED } from 'pages/Claim/const' export const NETWORK_LABELS: { [chainId in ChainId]?: string } = { [ChainId.RINKEBY]: 'Rinkeby', @@ -185,18 +198,36 @@ const UniIcon = styled.div` } ` +const VCowWrapper = styled(UNIWrapper)` + ${({ theme }) => theme.mediaWidth.upToSmall` + display: none; + `} +` + export default function Header() { + const location = useLocation() + const isClaimPage = location.pathname === '/claim' + const { account, chainId: connectedChainId } = useActiveWeb3React() const chainId = supportedChainId(connectedChainId) const userEthBalance = useETHBalances(account ? [account] : [])?.[account ?? ''] const nativeToken = chainId && (CHAIN_CURRENCY_LABELS[chainId] || 'ETH') const [darkMode, toggleDarkMode] = useDarkModeManager() + + // const toggleClaimModal = useToggleSelfClaimModal() + // const availableClaim: boolean = useUserHasAvailableClaim(account) + const [showUniBalanceModal, setShowUniBalanceModal] = useState(false) + // const showClaimPopup = useShowClaimPopup() + const [isOrdersPanelOpen, setIsOrdersPanelOpen] = useState(false) const closeOrdersPanel = () => setIsOrdersPanelOpen(false) const openOrdersPanel = () => setIsOrdersPanelOpen(true) const isMenuOpen = useModalOpen(ApplicationModal.MENU) + const history = useHistory() + const handleOnClickClaim = () => history.push('/claim') + // Toggle the 'noScroll' class on body, whenever the orders panel or flyout menu is open. // This removes the inner scrollbar on the page body, to prevent showing double scrollbars. useEffect(() => { @@ -209,6 +240,9 @@ export default function Header() { + setShowUniBalanceModal(false)}> + + <UniIcon> <LogoImage /> @@ -225,6 +259,12 @@ export default function Header() { <NetworkSelector /> </HeaderElement> <HeaderElement> + {IS_CLAIMING_ENABLED && ( + <VCowWrapper> + <CowClaimButton isClaimPage={isClaimPage} account={account} handleOnClickClaim={handleOnClickClaim} /> + </VCowWrapper> + )} + <AccountElement active={!!account} style={{ pointerEvents: 'auto' }}> {account && userEthBalance && ( <BalanceText style={{ flexShrink: 0, userSelect: 'none' }} pl="0.75rem" pr="0.5rem" fontWeight={500}> @@ -244,7 +284,7 @@ export default function Header() { {darkMode ? <Moon size={20} /> : <Sun size={20} />} </StyledMenuButton> </HeaderElementWrap> - <Menu darkMode={darkMode} toggleDarkMode={toggleDarkMode} /> + <Menu isClaimPage={isClaimPage} darkMode={darkMode} toggleDarkMode={toggleDarkMode} /> </HeaderControls> {isOrdersPanelOpen && <OrdersPanel closeOrdersPanel={closeOrdersPanel} />} </HeaderModWrapper> diff --git a/src/custom/components/Identicon/IdenticonMod.tsx b/src/custom/components/Identicon/IdenticonMod.tsx index 63f76b7c5..5dce6d156 100644 --- a/src/custom/components/Identicon/IdenticonMod.tsx +++ b/src/custom/components/Identicon/IdenticonMod.tsx @@ -12,8 +12,9 @@ export const StyledIdenticonContainer = styled.div` background-color: ${({ theme }) => theme.bg4}; ` -export default function Identicon({ size = 16 }: IdenticonProps) { - const { account, library } = useActiveWeb3React() +export default function Identicon({ account: customAccount, size = 16 }: IdenticonProps) { + const { account: chainAccount, library } = useActiveWeb3React() + const account = customAccount || chainAccount // restrict usage of Davatar until it stops sending 3p requests // see https://github.com/metaphor-xyz/davatar-helpers/issues/18 diff --git a/src/custom/components/Identicon/index.tsx b/src/custom/components/Identicon/index.tsx index dba566c76..17afff149 100644 --- a/src/custom/components/Identicon/index.tsx +++ b/src/custom/components/Identicon/index.tsx @@ -12,12 +12,13 @@ const Wrapper = styled.div<{ size?: number }>` export interface IdenticonProps { size?: number + account?: string } -export default function Identicon({ size }: IdenticonProps) { +export default function Identicon({ account, size }: IdenticonProps) { return ( <Wrapper size={size}> - <IdenticonMod size={size} /> + <IdenticonMod size={size} account={account} /> </Wrapper> ) } diff --git a/src/custom/components/Menu/MenuMod.tsx b/src/custom/components/Menu/MenuMod.tsx index 12821d9a3..212aa3426 100644 --- a/src/custom/components/Menu/MenuMod.tsx +++ b/src/custom/components/Menu/MenuMod.tsx @@ -29,7 +29,13 @@ import { useOnClickOutside } from 'hooks/useOnClickOutside' import { useModalOpen, useToggleModal } from 'state/application/hooks' import { ApplicationModal } from 'state/application/reducer' import { ExternalLink } from 'theme' -// import { ButtonPrimary } from 'components/Button' +import { ButtonPrimary } from 'components/Button' +/* import { useDarkModeManager } from 'state/user/hooks' + +import { L2_CHAIN_IDS, CHAIN_INFO, SupportedChainId } from 'constants/chains' +import { LOCALE_LABEL, SupportedLocale, SUPPORTED_LOCALES } from 'constants/locales' +import { useLocationLinkProps } from 'hooks/useLocationLinkProps' +import { useActiveLocale } from 'hooks/useActiveLocale' */ import { WithClassName } from 'types' export enum FlyoutAlignment { @@ -68,11 +74,11 @@ export const StyledMenuButton = styled.button` } ` -/* const UNIbutton = styled(ButtonPrimary)` +export const UNIbutton = styled(ButtonPrimary)` background-color: ${({ theme }) => theme.bg3}; background: radial-gradient(174.47% 188.91% at 1.84% 0%, #ff007a 0%, #2172e5 100%), #edeef2; border: none; -` */ +` export const StyledMenu = styled.div` margin-left: 0.5rem; diff --git a/src/custom/components/Menu/index.tsx b/src/custom/components/Menu/index.tsx index bd65fc723..41077c8c4 100644 --- a/src/custom/components/Menu/index.tsx +++ b/src/custom/components/Menu/index.tsx @@ -18,6 +18,8 @@ import ninjaCowImage from 'assets/cow-swap/ninja-cow.png' import { ApplicationModal } from 'state/application/reducer' import { getExplorerAddressLink } from 'utils/explorer' import { useHasOrders } from 'api/gnosisProtocol/hooks' +import { useHistory } from 'react-router-dom' +import CowClaimButton, { Wrapper as ClaimButtonWrapper } from 'components/CowClaimButton' import twitterImage from 'assets/cow-swap/twitter.svg' import discordImage from 'assets/cow-swap/discord.svg' @@ -29,7 +31,7 @@ const ResponsiveInternalMenuItem = styled(InternalMenuItem)` display: none; ${({ theme }) => theme.mediaWidth.upToMedium` - display: flex; + display: flex; `}; ` @@ -47,6 +49,7 @@ const MenuItemResponsive = styled(MenuItemResponsiveBase)` flex: 0 1 auto; padding: 16px; font-size: 18px; + svg { width: 18px; height: 18px; @@ -55,7 +58,7 @@ const MenuItemResponsive = styled(MenuItemResponsiveBase)` } ` -export const StyledMenu = styled(MenuMod)` +export const StyledMenu = styled(MenuMod)<{ isClaimPage: boolean }>` hr { margin: 15px 0; } @@ -95,9 +98,60 @@ export const StyledMenu = styled(MenuMod)` padding: 0 6px 0 0; } + ${ClaimButtonWrapper} { + margin: 0 0 12px; + display: none; + + ${({ theme }) => theme.mediaWidth.upToSmall` + display: flex; + margin: 0 12px 12px; + width: 100%; + height: 56px; + justify-content: center; + font-size: 19px; + + > span { + height: 30px; + width: 30px; + border-radius: 30px; + margin: 0 5px 0 0; + } + `} + } + ${StyledMenuButton} { height: 38px; + border-radius: 12px; + + ${({ theme }) => theme.mediaWidth.upToSmall` + &::before, + &::after { + content: ''; + position: absolute; + left: -1px; + top: -1px; + background: ${({ theme }) => + `linear-gradient(45deg, ${theme.primary1}, ${theme.primary2}, ${theme.primary3}, ${theme.bg4}, ${theme.primary1}, ${theme.primary2})`}; + background-size: 800%; + width: calc(100% + 2px); + height: calc(100% + 2px); + z-index: -1; + animation: glow 50s linear infinite; + transition: background-position 0.3s ease-in-out; + border-radius: 12px; + } + + &::after { + filter: blur(8px); + } + + &:hover::before, + &:hover::after { + animation: glow 12s linear infinite; + } } + + `}; ` const Policy = styled(InternalMenuItem).attrs((attrs) => ({ @@ -121,10 +175,9 @@ const MenuFlyout = styled(MenuFlyoutUni)` width: 100%; border-radius: 0; box-shadow: none; - padding: 0; overflow-y: auto; flex-flow: row wrap; - padding: 0 0 56px; + padding: 12px 0 100px; align-items: flex-start; align-content: flex-start; `}; @@ -149,6 +202,7 @@ const MenuFlyout = styled(MenuFlyoutUni)` align-items: center; } } + > a:hover { background: ${({ theme }) => theme.disabled}; border-radius: 6px; @@ -191,14 +245,16 @@ export const CloseMenu = styled.button` border-radius: 6px; justify-content: center; padding: 0; - margin: 0 0 8px; + margin: 8px 0 0; ${({ theme }) => theme.mediaWidth.upToSmall` height: 56px; border-radius: 0; - justify-content: flex-end; margin: 0; width: 100%; + position: fixed; + bottom: 0; + top: initial; `}; &::after { @@ -217,18 +273,26 @@ export const CloseMenu = styled.button` interface MenuProps { darkMode: boolean toggleDarkMode: () => void + isClaimPage: boolean } -export function Menu({ darkMode, toggleDarkMode }: MenuProps) { - const close = useToggleModal(ApplicationModal.MENU) +export function Menu({ darkMode, toggleDarkMode, isClaimPage }: MenuProps) { const { account, chainId } = useActiveWeb3React() const hasOrders = useHasOrders(account) const showOrdersLink = account && hasOrders + /* const showVCOWClaimOption = Boolean(!!account && !!chainId) */ + const close = useToggleModal(ApplicationModal.MENU) + const history = useHistory() + const handleOnClickClaim = () => { + close() + history.push('/claim') + } return ( - <StyledMenu> + <StyledMenu isClaimPage={isClaimPage}> <MenuFlyout> - <CloseMenu onClick={close} /> + <CowClaimButton isClaimPage={isClaimPage} handleOnClickClaim={handleOnClickClaim} /> + <ResponsiveInternalMenuItem to="/" onClick={close}> <Repeat size={14} /> Swap </ResponsiveInternalMenuItem> @@ -261,6 +325,9 @@ export function Menu({ darkMode, toggleDarkMode }: MenuProps) { Code </span> </MenuItem> + + <Separator /> + <MenuItem id="link" href={DISCORD_LINK}> <span aria-hidden="true" onClick={close} onKeyDown={close}> <SVG src={discordImage} description="Find CowSwap on Discord!" /> @@ -274,6 +341,8 @@ export function Menu({ darkMode, toggleDarkMode }: MenuProps) { </span> </MenuItem> + <Separator /> + <InternalMenuItem to="/play/mev-slicer" onClick={close}> <span role="img" aria-label="Play CowGame"> <img src={ninjaCowImage} alt="Play Cow MEV Slicer" /> @@ -308,8 +377,6 @@ export function Menu({ darkMode, toggleDarkMode }: MenuProps) { )} </MenuItemResponsive> - <Separator /> - <Policy to="/terms-and-conditions" onClick={close} onKeyDown={close}> Terms and conditions </Policy> @@ -317,6 +384,8 @@ export function Menu({ darkMode, toggleDarkMode }: MenuProps) { <Policy to="/privacy-policy">Privacy policy</Policy> <Policy to="/cookie-policy">Cookie policy</Policy> */} + + <CloseMenu onClick={close} /> </MenuFlyout> </StyledMenu> ) diff --git a/src/custom/components/Modal/index.ts b/src/custom/components/Modal/index.ts index bf4a268ab..0ac3357c6 100644 --- a/src/custom/components/Modal/index.ts +++ b/src/custom/components/Modal/index.ts @@ -5,10 +5,11 @@ import { HeaderRow, ContentWrapper, CloseIcon, HoverText } from 'components/Wall export * from '@src/components/Modal' export { default } from '@src/components/Modal' -export const GpModal = styled(Modal)` +export const GpModal = styled(Modal)<{ maxWidth?: number; backgroundColor?: string; border?: string }>` > [data-reach-dialog-content] { - background-color: ${({ theme }) => theme.bg1}; - max-width: 470px; + background-color: ${({ backgroundColor, theme }) => (backgroundColor ? backgroundColor : theme.bg1)}; + max-width: ${({ maxWidth }) => (maxWidth ? `${maxWidth}px` : '470px')}; + border: ${({ border }) => (border ? border : 'inherit')}; z-index: 100; ${({ theme }) => theme.mediaWidth.upToSmall` diff --git a/src/custom/components/Page/index.tsx b/src/custom/components/Page/index.tsx index e205ba14d..a12ac3a91 100644 --- a/src/custom/components/Page/index.tsx +++ b/src/custom/components/Page/index.tsx @@ -14,6 +14,9 @@ export const Title = styled.h1` font-size: 32px; margin: 24px 0 16px; color: ${({ theme }) => theme.text1}; + ${({ theme }) => theme.mediaWidth.upToVerySmall` + font-size: 24px; + `} ` export const Content = styled.div` diff --git a/src/custom/components/ProgressBar/index.tsx b/src/custom/components/ProgressBar/index.tsx new file mode 100644 index 000000000..75d62f9da --- /dev/null +++ b/src/custom/components/ProgressBar/index.tsx @@ -0,0 +1,62 @@ +import { ProgressBarWrap, ProgressContainer, Progress, Label, FlexWrap, HiddenRange, ProgressVal } from './styled' + +interface ProgressBarProps { + percentage: number // between 0 - 100 + onPercentageClick: (percentage: number) => void +} + +export function ProgressBar({ percentage, onPercentageClick }: ProgressBarProps) { + const statPercentages = [ + { + value: 0, + label: '0%', + }, + { + value: 25, + label: '25%', + }, + { + value: 50, + label: '50%', + }, + { + value: 75, + label: '75%', + }, + { + value: 100, + label: '100%', + }, + ] + const minVal = statPercentages[0].value + const maxVal = statPercentages[statPercentages.length - 1].value + + if (percentage > 100) { + percentage = 100 + } else if (percentage < 0) { + percentage = 0 + } + + return ( + <FlexWrap> + <ProgressBarWrap> + {statPercentages.map((item, index) => ( + <Label position={item.value} onClick={() => onPercentageClick(item.value)} key={`${item.value}-${index}`}> + {item.label} + </Label> + ))} + <ProgressContainer> + <HiddenRange + onChange={(e) => onPercentageClick(parseFloat(e.target.value))} + min={minVal} + max={maxVal} + value={percentage} + type="range" + /> + <Progress percentage={percentage} /> + <ProgressVal>{percentage}%</ProgressVal> + </ProgressContainer> + </ProgressBarWrap> + </FlexWrap> + ) +} diff --git a/src/custom/components/ProgressBar/styled.tsx b/src/custom/components/ProgressBar/styled.tsx new file mode 100644 index 000000000..b0869fcfa --- /dev/null +++ b/src/custom/components/ProgressBar/styled.tsx @@ -0,0 +1,77 @@ +import styled from 'styled-components/macro' +import * as CSS from 'csstype' +import { FlexWrap as FlexWrapMod } from 'pages/Profile/styled' +import { transparentize } from 'polished' + +export const FlexWrap = styled(FlexWrapMod)` + max-width: 100%; + align-items: flex-end; +` + +export const ProgressBarWrap = styled(FlexWrapMod)` + max-width: 500px; //optional + padding-top: 40px; + position: relative; +` + +export const ProgressContainer = styled.div` + background-color: ${({ theme }) => transparentize(0.61, theme.text1)}; + height: 24px; + width: 100% !important; + position: relative; + overflow: hidden; + border-radius: 10px; +` + +export const HiddenRange = styled.input` + width: 100%; + background-color: transparent; + z-index: 3; + position: relative; + opacity: 0; +` + +export const Progress = styled.div<Partial<CSS.Properties & { percentage: number }>>` + background-color: ${({ theme }) => theme.primary1}; + width: 100%; + max-width: ${(props) => props.percentage}%; + position: absolute; + top: 0; + left: 0; + bottom: 0; + transition: max-width 0.2s; +` + +export const ProgressVal = styled.span` + display: inline-block; + color: ${({ theme }) => theme.text1}; + position: absolute; + top: 50%; + left: 50%; + transform: translateX(-50%) translateY(-50%); + font-weight: bold; + font-size: 16px; +` + +export const Label = styled.a<Partial<CSS.Properties & { position: any }>>` + cursor: pointer; + position: absolute; + font-size: 12px; + color: ${({ theme }) => theme.text1}; + top: 10px; + left: ${(props) => props.position}%; + transform: translateX(-50%); + font-weight: bold; + text-decoration: underline; + + &:first-child { + transform: none; + } + &:nth-last-child(2) { + transform: translateX(-100%); + } + + &:hover { + text-decoration: none; + } +` diff --git a/src/custom/components/Settings/SettingsMod.tsx b/src/custom/components/Settings/SettingsMod.tsx index 314abcbea..a912a7e05 100644 --- a/src/custom/components/Settings/SettingsMod.tsx +++ b/src/custom/components/Settings/SettingsMod.tsx @@ -10,8 +10,8 @@ import { Text } from 'rebass' import styled, { ThemeContext } from 'styled-components/macro' import { useOnClickOutside } from 'hooks/useOnClickOutside' import { useModalOpen, useToggleSettingsMenu } from 'state/application/hooks' +import { useExpertModeManager, useRecipientToggleManager } from 'state/user/hooks' import { ApplicationModal } from 'state/application/reducer' -import { /* useClientSideRouter, */ useExpertModeManager } from 'state/user/hooks' import { TYPE } from 'theme' import { ButtonError } from 'components/Button' import { AutoColumn } from 'components/Column' @@ -127,6 +127,7 @@ export default function SettingsTab({ className, placeholderSlippage, SettingsBu const theme = useContext(ThemeContext) const [expertMode, toggleExpertMode] = useExpertModeManager() + const [recipientToggleVisible, toggleRecipientVisibility] = useRecipientToggleManager() // const [clientSideRouter, setClientSideRouter] = useClientSideRouter() @@ -244,15 +245,38 @@ export default function SettingsTab({ className, placeholderSlippage, SettingsBu expertMode ? () => { toggleExpertMode() + toggleRecipientVisibility(false) setShowConfirmation(false) } : () => { toggle() + toggleRecipientVisibility(true) setShowConfirmation(true) } } /> </RowBetween> + + <RowBetween> + <RowFixed> + <TYPE.black fontWeight={400} fontSize={14} color={theme.text2}> + <Trans>Toggle Recipient</Trans> + </TYPE.black> + <QuestionHelper + bgColor={theme.bg3} + color={theme.text1} + text={ + <Trans>Allows you to choose a destination address for the swap other than the connected one.</Trans> + } + /> + </RowFixed> + <Toggle + id="toggle-recipient-mode-button" + isActive={recipientToggleVisible || expertMode} + toggle={() => (expertMode ? null : toggleRecipientVisibility())} + className={expertMode ? 'disabled' : ''} + /> + </RowBetween> </AutoColumn> </MenuFlyout> )} diff --git a/src/custom/components/Stepper/index.tsx b/src/custom/components/Stepper/index.tsx new file mode 100644 index 000000000..1ec83b761 --- /dev/null +++ b/src/custom/components/Stepper/index.tsx @@ -0,0 +1,128 @@ +import styled from 'styled-components/macro' +import CheckCircle from 'assets/cow-swap/check.svg' +import { transparentize } from 'polished' + +export const Wrapper = styled.div` + width: 100%; + display: flex; + flex-flow: row wrap; + margin: 12px 0 24px; +` + +export const Step = styled.div<{ + totalSteps: number + isActiveStep: boolean + completedStep: boolean + circleSize?: number +}>` + --circleSize: 42px; + display: flex; + flex-flow: column wrap; + align-items: center; + position: relative; + flex: 1 1 ${({ totalSteps }) => `calc(100% / ${totalSteps})`}; + + &::before, + &::after { + content: ''; + position: absolute; + top: ${({ circleSize }) => (circleSize ? `calc(${circleSize / 2})px` : 'calc(var(--circleSize) / 2)')}; + height: 1px; + border-top: 1px solid ${({ theme }) => theme.border2}; + } + + &::before { + left: 0; + right: 50%; + margin-right: ${({ circleSize }) => (circleSize ? `${circleSize}px` : 'var(--circleSize)')}; + } + + &::after { + right: 0; + left: 50%; + margin-left: ${({ circleSize }) => (circleSize ? `${circleSize}px` : 'var(--circleSize)')}; + } + + &:first-child::before, + &:last-child::after { + content: none; + display: none; + } + + > span { + display: flex; + flex-flow: column wrap; + align-items: center; + justify-content: center; + width: ${({ circleSize }) => (circleSize ? `${circleSize}px` : 'var(--circleSize)')}; + height: ${({ circleSize }) => (circleSize ? `${circleSize}px` : 'var(--circleSize)')}; + margin: 0 auto 12px; + border-radius: ${({ circleSize }) => (circleSize ? `${circleSize}px` : 'var(--circleSize)')}; + text-align: center; + line-height: 1; + font-size: 100%; + position: relative; + color: ${({ isActiveStep, completedStep, theme }) => + completedStep ? theme.black : isActiveStep ? theme.black : transparentize(0.4, theme.text1)}; + background: ${({ isActiveStep, completedStep, theme, circleSize }) => + completedStep + ? `url(${CheckCircle}) no-repeat center/${circleSize ? `${circleSize}px` : 'var(--circleSize)'}` + : isActiveStep + ? theme.primary1 + : theme.blueShade3}; + + > small { + font-size: inherit; + color: inherit; + display: ${({ completedStep }) => (completedStep ? 'none' : 'block')}; + } + } + + > b { + color: ${({ isActiveStep, completedStep, theme }) => + completedStep ? theme.text1 : isActiveStep ? theme.text1 : transparentize(0.4, theme.text1)}; + font-weight: ${({ isActiveStep, completedStep }) => (completedStep ? '300' : isActiveStep ? 'bold' : '300')}; + } + + > i { + font-style: normal; + color: ${({ isActiveStep, completedStep, theme }) => + completedStep + ? transparentize(0.2, theme.text1) + : isActiveStep + ? transparentize(0.2, theme.text1) + : transparentize(0.4, theme.text1)}; + font-size: 12px; + margin: 6px 0 0; + padding: 0 24px; + text-align: center; + } +` + +interface StepperProps { + steps: { + title: string + subtitle?: string + }[] + activeStep: number +} + +export function Stepper({ steps, activeStep }: StepperProps) { + return ( + <Wrapper> + {steps.map(({ title, subtitle }, index) => { + const completedStep = activeStep > index + const isActiveStep = activeStep === index + return ( + <Step key={index} totalSteps={steps.length} isActiveStep={isActiveStep} completedStep={completedStep}> + <span> + <small>{index + 1}</small> + </span> + <b>{title}</b> + <i>{subtitle}</i> + </Step> + ) + })} + </Wrapper> + ) +} diff --git a/src/custom/components/Toggle/index.tsx b/src/custom/components/Toggle/index.tsx index 5028727c7..a0f790a43 100644 --- a/src/custom/components/Toggle/index.tsx +++ b/src/custom/components/Toggle/index.tsx @@ -16,9 +16,17 @@ const WrappedToggle = styled(ToggleUni)` border: 2px solid ${({ theme }) => theme.text1}; } } - .disabled { - background: ${({ theme }) => theme.primary1}; - color: ${({ theme }) => theme.text2}; + + &.disabled { + cursor: default; + + ${ToggleElement} { + opacity: 0.5; + + &:hover { + border: 2px solid transparent; + } + } } ` diff --git a/src/custom/components/TransactionConfirmationModal/index.tsx b/src/custom/components/TransactionConfirmationModal/index.tsx index 947fd5531..ae6c4a449 100644 --- a/src/custom/components/TransactionConfirmationModal/index.tsx +++ b/src/custom/components/TransactionConfirmationModal/index.tsx @@ -206,8 +206,6 @@ const StepsIconWrapper = styled.div` --circle-size: 65px; --border-radius: 100%; --border-size: 2px; - --border-bg: conic-gradient(${({ theme }) => theme.bg3} 40grad, 80grad, ${({ theme }) => theme.primary1} 360grad); - border-radius: var(--circle-size); height: var(--circle-size); width: var(--circle-size); @@ -266,8 +264,8 @@ const StepsWrapper = styled.div` ${StepsIconWrapper} { &::before { content: ''; + ${({ theme }) => theme.iconGradientBorder}; display: block; - background: var(--border-bg); width: var(--circle-size); padding: 0; position: absolute; @@ -357,6 +355,7 @@ export enum OperationType { WRAP_ETHER, UNWRAP_WETH, APPROVE_TOKEN, + REVOKE_APPROVE_TOKEN, ORDER_SIGN, ORDER_CANCEL, } @@ -384,6 +383,8 @@ function getOperationMessage(operationType: OperationType, chainId: number): str return 'Approving token' case OperationType.ORDER_CANCEL: return 'Soft canceling your order' + case OperationType.REVOKE_APPROVE_TOKEN: + return 'Revoking token approval' default: return 'Almost there!' @@ -398,6 +399,8 @@ function getOperationLabel(operationType: OperationType): string { return t`unwrapping` case OperationType.APPROVE_TOKEN: return t`token approval` + case OperationType.REVOKE_APPROVE_TOKEN: + return t`revoking token approval` case OperationType.ORDER_SIGN: return t`order` case OperationType.ORDER_CANCEL: diff --git a/src/custom/components/swap/EthWethWrap/helpers.ts b/src/custom/components/swap/EthWethWrap/helpers.ts index 83ec7f5b5..4c290a730 100644 --- a/src/custom/components/swap/EthWethWrap/helpers.ts +++ b/src/custom/components/swap/EthWethWrap/helpers.ts @@ -1,7 +1,9 @@ import { parseUnits } from '@ethersproject/units' import { CurrencyAmount, Currency } from '@uniswap/sdk-core' +import { BigNumber } from '@ethersproject/bignumber' // eslint-disable-next-line no-restricted-imports import { t } from '@lingui/macro' +import { useGasPrices } from 'state/gas/hooks' export const MINIMUM_TXS = '10' export const AVG_APPROVE_COST_GWEI = '50000' @@ -18,11 +20,12 @@ export function _isLowBalanceCheck({ nativeInput, balance, }: { - threshold: CurrencyAmount<Currency> - txCost: CurrencyAmount<Currency> + threshold?: CurrencyAmount<Currency> + txCost?: CurrencyAmount<Currency> nativeInput?: CurrencyAmount<Currency> balance?: CurrencyAmount<Currency> }) { + if (!threshold || !txCost) return false if (!nativeInput || !balance || nativeInput.add(txCost).greaterThan(balance)) return true // OK if: users_balance - (amt_input + 1_tx_cost) > low_balance_threshold return balance.subtract(nativeInput.add(txCost)).lessThan(threshold) @@ -35,11 +38,29 @@ export const _getAvailableTransactions = ({ }: { nativeBalance?: CurrencyAmount<Currency> nativeInput?: CurrencyAmount<Currency> - singleTxCost: CurrencyAmount<Currency> + singleTxCost?: CurrencyAmount<Currency> }) => { - if (!nativeBalance || !nativeInput || nativeBalance.lessThan(nativeInput.add(singleTxCost))) return null + if (!nativeBalance || !nativeInput || !singleTxCost || nativeBalance.lessThan(nativeInput.add(singleTxCost))) { + return null + } // USER_BALANCE - (USER_WRAP_AMT + 1_TX_CST) / 1_TX_COST = AVAILABLE_TXS const txsAvailable = nativeBalance.subtract(nativeInput.add(singleTxCost)).divide(singleTxCost) return txsAvailable.lessThan('1') ? null : txsAvailable.toSignificant(1) } + +export function _estimateTxCost(gasPrice: ReturnType<typeof useGasPrices>, native: Currency | undefined) { + if (!native) { + return {} + } + // TODO: should use DEFAULT_GAS_FEE from backup source + // when/if implemented + const gas = gasPrice?.standard || DEFAULT_GAS_FEE + + const amount = BigNumber.from(gas).mul(MINIMUM_TXS).mul(AVG_APPROVE_COST_GWEI) + + return { + multiTxCost: CurrencyAmount.fromRawAmount(native, amount.toString()), + singleTxCost: CurrencyAmount.fromFractionalAmount(native, amount.toString(), MINIMUM_TXS), + } +} diff --git a/src/custom/components/swap/EthWethWrap/index.tsx b/src/custom/components/swap/EthWethWrap/index.tsx index 02dacdf4b..de251cf45 100644 --- a/src/custom/components/swap/EthWethWrap/index.tsx +++ b/src/custom/components/swap/EthWethWrap/index.tsx @@ -14,15 +14,7 @@ import { useIsTransactionPending } from 'state/enhancedTransactions/hooks' import Modal from 'components/Modal' import { useGasPrices } from 'state/gas/hooks' import { useActiveWeb3React } from 'hooks/web3' -import { BigNumber } from '@ethersproject/bignumber' -import { - DEFAULT_GAS_FEE, - MINIMUM_TXS, - AVG_APPROVE_COST_GWEI, - _isLowBalanceCheck, - _setNativeLowBalanceError, - _getAvailableTransactions, -} from './helpers' +import { _isLowBalanceCheck, _setNativeLowBalanceError, _getAvailableTransactions, _estimateTxCost } from './helpers' import { Trans } from '@lingui/macro' const Wrapper = styled.div` @@ -152,18 +144,7 @@ export default function EthWethWrap({ account, native, nativeInput, wrapped, wra const gasPrice = useGasPrices(chainId) // returns the cost of 1 tx and multi txs - const { multiTxCost, singleTxCost } = useMemo(() => { - // TODO: should use DEFAULT_GAS_FEE from backup source - // when/if implemented - const gas = gasPrice?.standard || DEFAULT_GAS_FEE - - const amount = BigNumber.from(gas).mul(MINIMUM_TXS).mul(AVG_APPROVE_COST_GWEI) - - return { - multiTxCost: CurrencyAmount.fromRawAmount(native, amount.toString()), - singleTxCost: CurrencyAmount.fromFractionalAmount(native, amount.toString(), MINIMUM_TXS), - } - }, [gasPrice, native]) + const { multiTxCost, singleTxCost } = useMemo(() => _estimateTxCost(gasPrice, native), [gasPrice, native]) const isWrapPending = useIsTransactionPending(pendingHash) const [nativeBalance, wrappedBalance] = useCurrencyBalances(account, [native, wrapped]) diff --git a/src/custom/components/swap/SwapModalHeader/SwapModalHeaderMod.tsx b/src/custom/components/swap/SwapModalHeader/SwapModalHeaderMod.tsx index 69ce8f653..c12049e01 100644 --- a/src/custom/components/swap/SwapModalHeader/SwapModalHeaderMod.tsx +++ b/src/custom/components/swap/SwapModalHeader/SwapModalHeaderMod.tsx @@ -280,8 +280,8 @@ SwapModalHeaderProps) { )} </AutoColumn> {recipient !== null ? ( - <AutoColumn justify="flex-start" gap="sm" style={{ padding: '12px 0 0 0px' }}> - <TYPE.main> + <AutoColumn justify="flex-start" gap="sm"> + <TYPE.main style={{ padding: '0.75rem 1rem' }}> <Trans> Output will be sent to{' '} <b title={recipient}>{isAddress(recipient) ? shortenAddress(recipient) : recipient}</b> diff --git a/src/custom/constants/index.ts b/src/custom/constants/index.ts index 48660c248..7cebfc266 100644 --- a/src/custom/constants/index.ts +++ b/src/custom/constants/index.ts @@ -10,6 +10,7 @@ export const INITIAL_ALLOWED_SLIPPAGE_PERCENT = new Percent('5', '1000') // 0.5% export const RADIX_DECIMAL = 10 export const RADIX_HEX = 16 +// TODO: remove, this is duplicated with `import { ONE_HUNDRED_PERCENT } from 'constants/misc'` export const ONE_HUNDRED_PERCENT = new Percent(1, 1) export const DEFAULT_DECIMALS = 18 @@ -52,6 +53,12 @@ export const GP_VAULT_RELAYER: Partial<Record<number, string>> = { [ChainId.XDAI]: GPv2VaultRelayer[ChainId.XDAI].address, } +export const V_COW_CONTRACT_ADDRESS: Partial<Record<number, string>> = { + [ChainId.MAINNET]: '0x6d04B3ad33594978D0D4B01CdB7c3bA4a90a7DFe', + [ChainId.XDAI]: '0xA3A674a40709A837A5E742C2866eda7d3b35a7c0', + [ChainId.RINKEBY]: '0xD7Dd9397Fb942565959c77f8e112ec5aa7D8C92c', +} + // See https://github.com/gnosis/gp-v2-contracts/commit/821b5a8da213297b0f7f1d8b17c893c5627020af#diff-12bbbe13cd5cf42d639e34a39d8795021ba40d3ee1e1a8282df652eb161a11d6R13 export const NATIVE_CURRENCY_BUY_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' export const NATIVE_CURRENCY_BUY_TOKEN: { [chainId in ChainId | number]: Token } = { @@ -77,8 +84,6 @@ export const WETH_LOGO_URI = export const XDAI_LOGO_URI = 'https://raw.githubusercontent.com/1Hive/default-token-list/master/src/assets/xdai/0xe91d153e0b41518a2ce8dd3d7944fa863463a97d/logo.png' -// 0.1 balance threshold -export const LOW_NATIVE_BALANCE_THRESHOLD = new Fraction('1', '10') export const DOCS_LINK = 'https://docs.cow.fi' export const CONTRACTS_CODE_LINK = 'https://github.com/gnosis/gp-v2-contracts' export const CODE_LINK = 'https://github.com/gnosis/gp-swap-ui' diff --git a/src/custom/constants/tokens/index.ts b/src/custom/constants/tokens/index.ts index 2a3745e4a..9f7d5bf09 100644 --- a/src/custom/constants/tokens/index.ts +++ b/src/custom/constants/tokens/index.ts @@ -1,8 +1,11 @@ import { ChainId } from '@uniswap/sdk' -import { WETH9 } from '@uniswap/sdk-core' +import { WETH9, Token } from '@uniswap/sdk-core' import { DAI_RINKEBY, USDC_RINKEBY, USDT_RINKEBY, WBTC_RINKEBY } from 'utils/rinkeby/constants' -import { DAI, USDC, USDT, WBTC } from 'constants/tokens' +import { DAI, USDC as USDC_MAINNET, USDT, WBTC } from '@src/constants/tokens' import { USDC_XDAI, /*USDT_XDAI,*/ WBTC_XDAI, WETH_XDAI, WXDAI } from 'utils/xdai/constants' +import wxDaiLogo from 'assets/images/wxdai.png' +import { SupportedChainId } from 'constants/chains' +import { V_COW_CONTRACT_ADDRESS } from 'constants/index' export * from './tokensMod' @@ -15,16 +18,75 @@ const WETH_ADDRESS_MAINNET = WETH9[ChainId.MAINNET].address export const ADDRESS_IMAGE_OVERRIDE = { // Rinkeby [DAI_RINKEBY.address]: getTrustImage(DAI.address), - [USDC_RINKEBY.address]: getTrustImage(USDC.address), + [USDC_RINKEBY.address]: getTrustImage(USDC_MAINNET.address), [USDT_RINKEBY.address]: getTrustImage(USDT.address), [WBTC_RINKEBY.address]: getTrustImage(WBTC.address), [WETH9[ChainId.RINKEBY].address]: getTrustImage(WETH_ADDRESS_MAINNET), // xDai - [USDC_XDAI.address]: getTrustImage(USDC.address), + [USDC_XDAI.address]: getTrustImage(USDC_MAINNET.address), // [USDT_XDAI.address]: getTrustImage(USDT.address), [WBTC_XDAI.address]: getTrustImage(WBTC.address), - [WXDAI.address]: - 'https://raw.githubusercontent.com/1Hive/default-token-list/master/src/assets/xdai/0xe91d153e0b41518a2ce8dd3d7944fa863463a97d/logo.png', + [WXDAI.address]: wxDaiLogo, [WETH_XDAI.address]: getTrustImage(WETH_ADDRESS_MAINNET), } + +const V_COW_TOKEN_MAINNET = new Token( + SupportedChainId.MAINNET, + V_COW_CONTRACT_ADDRESS[SupportedChainId.MAINNET] || '', + 18, + 'vCOW', + 'CoW Protocol Virtual Token' +) + +const V_COW_TOKEN_XDAI = new Token( + SupportedChainId.XDAI, + V_COW_CONTRACT_ADDRESS[SupportedChainId.XDAI] || '', + 18, + 'vCOW', + 'CoW Protocol Virtual Token' +) + +const V_COW_TOKEN_RINKEBY = new Token( + SupportedChainId.RINKEBY, + V_COW_CONTRACT_ADDRESS[SupportedChainId.RINKEBY] || '', + 18, + 'vCOW', + 'CoW Protocol Virtual Token' +) + +export const V_COW: Record<number, Token> = { + [SupportedChainId.MAINNET]: V_COW_TOKEN_MAINNET, + [SupportedChainId.XDAI]: V_COW_TOKEN_XDAI, + [SupportedChainId.RINKEBY]: V_COW_TOKEN_RINKEBY, +} + +export const GNO: Record<SupportedChainId, Token> = { + [SupportedChainId.MAINNET]: new Token( + SupportedChainId.MAINNET, + '0x6810e776880c02933d47db1b9fc05908e5386b96', + 18, + 'GNO', + 'Gnosis' + ), + [SupportedChainId.XDAI]: new Token( + SupportedChainId.XDAI, + '0x9c58bacc331c9aa871afd802db6379a98e80cedb', + 18, + 'GNO', + 'Gnosis' + ), + [SupportedChainId.RINKEBY]: new Token( + SupportedChainId.RINKEBY, + '0xd0dab4e640d95e9e8a47545598c33e31bdb53c7c', + 18, + 'GNO', + 'Gnosis' + ), +} + +export const USDC_BY_CHAIN: Record<SupportedChainId, Token> = { + [SupportedChainId.MAINNET]: USDC_MAINNET, + [SupportedChainId.XDAI]: USDC_XDAI, + [SupportedChainId.RINKEBY]: USDC_RINKEBY, +} diff --git a/src/custom/hooks/useApproveCallback/index.ts b/src/custom/hooks/useApproveCallback/index.ts index a49a901de..2b958691a 100644 --- a/src/custom/hooks/useApproveCallback/index.ts +++ b/src/custom/hooks/useApproveCallback/index.ts @@ -1,21 +1,34 @@ +import { Currency, CurrencyAmount, MaxUint256, Percent } from '@uniswap/sdk-core' import { useActiveWeb3React } from '@src/hooks/web3' import { Field } from '@src/state/swap/actions' import { computeSlippageAdjustedAmounts } from 'utils/prices' import { useMemo } from 'react' -import { GP_VAULT_RELAYER } from 'constants/index' +import { GP_VAULT_RELAYER, V_COW_CONTRACT_ADDRESS } from 'constants/index' import TradeGp from 'state/swap/TradeGp' -import { ZERO_PERCENT } from 'constants/misc' -import { useApproveCallback } from './useApproveCallbackMod' +import { ApproveCallbackParams, useApproveCallback } from './useApproveCallbackMod' export { ApprovalState, useApproveCallback } from './useApproveCallbackMod' +import { ClaimType } from 'state/claim/hooks' +import { supportedChainId } from 'utils/supportedChainId' +import { EnhancedUserClaimData } from 'pages/Claim/types' + +type ApproveCallbackFromTradeParams = Pick< + ApproveCallbackParams, + 'openTransactionConfirmationModal' | 'closeModals' | 'amountToCheckAgainstAllowance' +> & { + trade: TradeGp | undefined + allowedSlippage: Percent +} + // export function useApproveCallbackFromTrade(trade?: Trade, allowedSlippage = 0) { -export function useApproveCallbackFromTrade( - openTransactionConfirmationModal: (message: string) => void, - closeModals: () => void, - trade?: TradeGp, - allowedSlippage = ZERO_PERCENT -) { +export function useApproveCallbackFromTrade({ + openTransactionConfirmationModal, + closeModals, + trade, + allowedSlippage, + amountToCheckAgainstAllowance, +}: ApproveCallbackFromTradeParams) { const { chainId } = useActiveWeb3React() const amountToApprove = useMemo(() => { @@ -28,5 +41,58 @@ export function useApproveCallbackFromTrade( const vaultRelayer = chainId ? GP_VAULT_RELAYER[chainId] : undefined - return useApproveCallback(openTransactionConfirmationModal, closeModals, amountToApprove, vaultRelayer) + return useApproveCallback({ + openTransactionConfirmationModal, + closeModals, + amountToApprove, + spender: vaultRelayer, + amountToCheckAgainstAllowance, + }) +} + +export type OptionalApproveCallbackParams = { + transactionSummary?: string + modalMessage?: string +} + +type ApproveCallbackFromClaimParams = Omit< + ApproveCallbackParams, + 'spender' | 'amountToApprove' | 'amountToCheckAgainstAllowance' +> & { + claim: EnhancedUserClaimData + investmentAmount?: CurrencyAmount<Currency> +} + +export function useApproveCallbackFromClaim({ + openTransactionConfirmationModal, + closeModals, + claim, + investmentAmount, +}: ApproveCallbackFromClaimParams) { + const { chainId } = useActiveWeb3React() + const supportedChain = supportedChainId(chainId) + + const vCowContract = chainId ? V_COW_CONTRACT_ADDRESS[chainId] : undefined + + // Claim only approves GNO and USDC (GnoOption & Investor, respectively.) + const approveAmounts = useMemo(() => { + if (supportedChain && (claim.type === ClaimType.GnoOption || claim.type === ClaimType.Investor)) { + const investmentCurrency = claim.currencyAmount?.currency as Currency + return { + amountToApprove: CurrencyAmount.fromRawAmount(investmentCurrency, MaxUint256), + // pass in a custom investmentAmount or just use the maxCost + amountToCheckAgainstAllowance: investmentAmount || claim.cost, + } + } + return undefined + }, [claim.cost, claim.currencyAmount?.currency, claim.type, investmentAmount, supportedChain]) + + // Params: modal cbs, amountToApprove: token user is investing e.g, spender: vcow token contract + return useApproveCallback({ + openTransactionConfirmationModal, + closeModals, + spender: vCowContract, + amountToApprove: approveAmounts?.amountToApprove, + amountToCheckAgainstAllowance: approveAmounts?.amountToCheckAgainstAllowance, + }) } diff --git a/src/custom/hooks/useApproveCallback/useApproveCallbackMod.ts b/src/custom/hooks/useApproveCallback/useApproveCallbackMod.ts index 1b7fe3973..823d80319 100644 --- a/src/custom/hooks/useApproveCallback/useApproveCallbackMod.ts +++ b/src/custom/hooks/useApproveCallback/useApproveCallbackMod.ts @@ -1,20 +1,23 @@ import { MaxUint256 } from '@ethersproject/constants' import { TransactionResponse } from '@ethersproject/providers' import { BigNumber } from '@ethersproject/bignumber' -import { Currency, CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core' -import { Trade as V2Trade } from '@uniswap/v2-sdk' -import { Trade as V3Trade } from '@uniswap/v3-sdk' +import { Currency, CurrencyAmount /* , Percent, TradeType */ } from '@uniswap/sdk-core' +// import { Trade as V2Trade } from '@uniswap/v2-sdk' +// import { Trade as V3Trade } from '@uniswap/v3-sdk' import { useCallback, useMemo } from 'react' -import { SWAP_ROUTER_ADDRESSES, V2_ROUTER_ADDRESS } from 'constants/addresses' +// import { SWAP_ROUTER_ADDRESSES, V2_ROUTER_ADDRESS } from 'constants/addresses' import { useHasPendingApproval, useTransactionAdder } from 'state/enhancedTransactions/hooks' import { calculateGasMargin } from 'utils/calculateGasMargin' import { useTokenContract } from 'hooks/useContract' import { useTokenAllowance } from 'hooks/useTokenAllowance' import { useActiveWeb3React } from 'hooks/web3' +import { OptionalApproveCallbackParams } from '.' +import { useCurrency } from 'hooks/Tokens' +import { OperationType } from 'components/TransactionConfirmationModal' // Use a 150K gas as a fallback if there's issue calculating the gas estimation (fixes some issues with some nodes failing to calculate gas costs for SC wallets) -const APPROVE_GAS_LIMIT_DEFAULT = BigNumber.from('150000') +export const APPROVE_GAS_LIMIT_DEFAULT = BigNumber.from('150000') export enum ApprovalState { UNKNOWN = 'UNKNOWN', @@ -23,18 +26,52 @@ export enum ApprovalState { APPROVED = 'APPROVED', } -// returns a variable indicating the state of the approval and a function which approves if necessary or early returns -export function useApproveCallback( - openTransactionConfirmationModal: (message: string) => void, - closeModals: () => void, - amountToApprove?: CurrencyAmount<Currency>, +export interface ApproveCallbackParams { + openTransactionConfirmationModal: (message: string, operationType: OperationType) => void + closeModals: () => void + amountToApprove?: CurrencyAmount<Currency> spender?: string -): [ApprovalState, () => Promise<void>] { + amountToCheckAgainstAllowance?: CurrencyAmount<Currency> +} + +// returns a variable indicating the state of the approval and a function which approves if necessary or early returns +export function useApproveCallback({ + openTransactionConfirmationModal, + closeModals, + amountToApprove, + spender, + amountToCheckAgainstAllowance, +}: ApproveCallbackParams) { const { account, chainId } = useActiveWeb3React() const token = amountToApprove?.currency?.isToken ? amountToApprove.currency : undefined const currentAllowance = useTokenAllowance(token, account ?? undefined, spender) const pendingApproval = useHasPendingApproval(token?.address, spender) + const spenderCurrency = useCurrency(spender) + // TODO: Nice to have, can be deleted + { + process.env.NODE_ENV !== 'production' && + console.debug(` + $$$$Approval metrics: + ==== + CurrentAllowance: ${currentAllowance?.toExact()} + raw: ${currentAllowance?.quotient.toString()} + ==== + amountToCheckAgainstApproval: ${amountToCheckAgainstAllowance?.toExact()} + raw: ${amountToCheckAgainstAllowance?.quotient.toString()} + ==== + amountToApprove: ${amountToApprove?.toExact()} + raw: ${amountToApprove?.quotient.toString()} + ==== + Needs approval?: ${ + !amountToCheckAgainstAllowance && !amountToApprove + ? 'Unknown - no amounts' + : currentAllowance && amountToApprove + ? currentAllowance.lessThan(amountToCheckAgainstAllowance || amountToApprove) + : 'unknown no currentAllowance' + } + `) + } // check the current approval status const approvalState: ApprovalState = useMemo(() => { if (!amountToApprove || !spender) return ApprovalState.UNKNOWN @@ -42,121 +79,200 @@ export function useApproveCallback( // we might not have enough data to know whether or not we need to approve if (!currentAllowance) return ApprovalState.UNKNOWN - // Return approval state - if (currentAllowance.lessThan(amountToApprove)) { - return pendingApproval ? ApprovalState.PENDING : ApprovalState.NOT_APPROVED - } else { - // Enough allowance - return ApprovalState.APPROVED - } - }, [amountToApprove, currentAllowance, pendingApproval, spender]) + // amountToApprove will be defined if currentAllowance is + return currentAllowance.lessThan(amountToCheckAgainstAllowance || amountToApprove) + ? pendingApproval + ? ApprovalState.PENDING + : ApprovalState.NOT_APPROVED + : ApprovalState.APPROVED + }, [amountToApprove, amountToCheckAgainstAllowance, currentAllowance, pendingApproval, spender]) const tokenContract = useTokenContract(token?.address) const addTransaction = useTransactionAdder() - const approve = useCallback(async (): Promise<void> => { - if (approvalState !== ApprovalState.NOT_APPROVED) { - console.error('approve was called unnecessarily') - return - } - if (!chainId) { - console.error('no chainId') - return - } + const approve = useCallback( + async (optionalParams?: OptionalApproveCallbackParams): Promise<void> => { + if (approvalState !== ApprovalState.NOT_APPROVED) { + console.error('approve was called unnecessarily') + return + } + if (!chainId) { + console.error('no chainId') + return + } - if (!token) { - console.error('no token') - return - } + if (!token) { + console.error('no token') + return + } - if (!tokenContract) { - console.error('tokenContract is null') - return - } + if (!tokenContract) { + console.error('tokenContract is null') + return + } - if (!amountToApprove) { - console.error('missing amount to approve') - return - } + if (!amountToApprove) { + console.error('missing amount to approve') + return + } - if (!spender) { - console.error('no spender') - return - } + if (!spender) { + console.error('no spender') + return + } - let useExact = false - const estimatedGas = await tokenContract.estimateGas.approve(spender, MaxUint256).catch(() => { - // general fallback for tokens who restrict approval amounts - useExact = true - return tokenContract.estimateGas.approve(spender, amountToApprove.quotient.toString()).catch((error) => { - console.log( - '[useApproveCallbackMod] Error estimating gas for approval. Using default gas limit ' + - APPROVE_GAS_LIMIT_DEFAULT.toString(), - error - ) - useExact = false - return APPROVE_GAS_LIMIT_DEFAULT + let useExact = false + const estimatedGas = await tokenContract.estimateGas.approve(spender, MaxUint256).catch(() => { + // general fallback for tokens who restrict approval amounts + useExact = true + return tokenContract.estimateGas.approve(spender, amountToApprove.quotient.toString()).catch((error) => { + console.log( + '[useApproveCallbackMod] Error estimating gas for approval. Using default gas limit ' + + APPROVE_GAS_LIMIT_DEFAULT.toString(), + error + ) + useExact = false + return APPROVE_GAS_LIMIT_DEFAULT + }) }) - }) - openTransactionConfirmationModal(`Approving ${amountToApprove.currency.symbol} for trading`) - return ( - tokenContract - .approve(spender, useExact ? amountToApprove.quotient.toString() : MaxUint256, { - gasLimit: calculateGasMargin(chainId, estimatedGas), - }) - .then((response: TransactionResponse) => { - addTransaction({ - hash: response.hash, - summary: 'Approve ' + amountToApprove.currency.symbol, - approval: { tokenAddress: token.address, spender }, + openTransactionConfirmationModal( + optionalParams?.modalMessage || `Approving ${amountToApprove.currency.symbol} for trading`, + OperationType.APPROVE_TOKEN + ) + return ( + tokenContract + .approve(spender, useExact ? amountToApprove.quotient.toString() : MaxUint256, { + gasLimit: calculateGasMargin(chainId, estimatedGas), }) + .then((response: TransactionResponse) => { + addTransaction({ + hash: response.hash, + summary: optionalParams?.transactionSummary || 'Approve ' + amountToApprove.currency.symbol, + approval: { tokenAddress: token.address, spender }, + }) + }) + // .catch((error: Error) => { + // console.debug('Failed to approve token', error) + // throw error + // }) + .finally(closeModals) + ) + }, + [ + chainId, + approvalState, + token, + tokenContract, + amountToApprove, + spender, + addTransaction, + openTransactionConfirmationModal, + closeModals, + ] + ) + + const revokeApprove = useCallback( + async (optionalParams?: OptionalApproveCallbackParams): Promise<void> => { + if (approvalState === ApprovalState.NOT_APPROVED) { + console.error('Revoke approve was called unnecessarily') + return + } + if (!chainId) { + console.error('no chainId') + return + } + + if (!token) { + console.error('no token') + return + } + + if (!tokenContract) { + console.error('tokenContract is null') + return + } + + if (!spender) { + console.error('no spender') + return + } + + const estimatedGas = await tokenContract.estimateGas.approve(spender, MaxUint256).catch(() => { + // general fallback for tokens who restrict approval amounts + return tokenContract.estimateGas.approve(spender, '0').catch((error) => { + console.log( + '[useApproveCallbackMod] Error estimating gas for revoking approval. Using default gas limit ' + + APPROVE_GAS_LIMIT_DEFAULT.toString(), + error + ) + return APPROVE_GAS_LIMIT_DEFAULT }) - // .catch((error: Error) => { - // console.debug('Failed to approve token', error) - // throw error - // }) - .finally(closeModals) - ) - }, [ - chainId, - approvalState, - token, - tokenContract, - amountToApprove, - spender, - addTransaction, - openTransactionConfirmationModal, - closeModals, - ]) - - return [approvalState, approve] -} + }) -// wraps useApproveCallback in the context of a swap -export function useApproveCallbackFromTrade( - openTransactionConfirmationModal: (message: string) => void, - closeModals: () => void, - trade: V2Trade<Currency, Currency, TradeType> | V3Trade<Currency, Currency, TradeType> | undefined, - allowedSlippage: Percent -) { - const { chainId } = useActiveWeb3React() - const v3SwapRouterAddress = chainId ? SWAP_ROUTER_ADDRESSES[chainId] : undefined - - const amountToApprove = useMemo( - () => (trade && trade.inputAmount.currency.isToken ? trade.maximumAmountIn(allowedSlippage) : undefined), - [trade, allowedSlippage] - ) - return useApproveCallback( - openTransactionConfirmationModal, - closeModals, - amountToApprove, - chainId - ? trade instanceof V2Trade - ? V2_ROUTER_ADDRESS[chainId] - : trade instanceof V3Trade - ? v3SwapRouterAddress - : undefined - : undefined + openTransactionConfirmationModal( + optionalParams?.modalMessage || `Revoke ${token.symbol} approval from ${spenderCurrency?.symbol || spender}`, + OperationType.REVOKE_APPROVE_TOKEN + ) + return ( + tokenContract + .approve(spender, '0', { + gasLimit: calculateGasMargin(chainId, estimatedGas), + }) + .then((response: TransactionResponse) => { + addTransaction({ + hash: response.hash, + summary: optionalParams?.transactionSummary || `Revoke ${token.symbol} approval from ${spender}`, + approval: { tokenAddress: token.wrapped.address, spender }, + }) + }) + // .catch((error: Error) => { + // console.debug('Failed to approve token', error) + // throw error + // }) + .finally(closeModals) + ) + }, + [ + approvalState, + chainId, + token, + tokenContract, + spender, + spenderCurrency?.symbol, + openTransactionConfirmationModal, + closeModals, + addTransaction, + ] ) + + return { approvalState, approve, revokeApprove, isPendingApproval: pendingApproval } } + +// wraps useApproveCallback in the context of a swap +// export function useApproveCallbackFromTrade( +// openTransactionConfirmationModal: (message: string) => void, +// closeModals: () => void, +// trade: V2Trade<Currency, Currency, TradeType> | V3Trade<Currency, Currency, TradeType> | undefined, +// allowedSlippage: Percent +// ) { +// const { chainId } = useActiveWeb3React() +// const v3SwapRouterAddress = chainId ? SWAP_ROUTER_ADDRESSES[chainId] : undefined + +// const amountToApprove = useMemo( +// () => (trade && trade.inputAmount.currency.isToken ? trade.maximumAmountIn(allowedSlippage) : undefined), +// [trade, allowedSlippage] +// ) +// return useApproveCallback( +// openTransactionConfirmationModal, +// closeModals, +// amountToApprove, +// chainId +// ? trade instanceof V2Trade +// ? V2_ROUTER_ADDRESS[chainId] +// : trade instanceof V3Trade +// ? v3SwapRouterAddress +// : undefined +// : undefined +// ) +// } diff --git a/src/custom/hooks/useContract.ts b/src/custom/hooks/useContract.ts index 8dfd0d40c..a4d49afd2 100644 --- a/src/custom/hooks/useContract.ts +++ b/src/custom/hooks/useContract.ts @@ -4,7 +4,7 @@ import { useActiveWeb3React } from 'hooks/web3' import { useContract } from '@src/hooks/useContract' -import { GP_SETTLEMENT_CONTRACT_ADDRESS } from 'constants/index' +import { GP_SETTLEMENT_CONTRACT_ADDRESS, V_COW_CONTRACT_ADDRESS } from 'constants/index' import { SupportedChainId as ChainId } from 'constants/chains' import ENS_ABI from 'abis/ens-registrar.json' @@ -12,8 +12,9 @@ import { getContract } from 'utils' import ERC20_ABI from 'abis/erc20.json' import ERC20_BYTES32_ABI from 'abis/erc20_bytes32.json' -import { GPv2Settlement, Erc20 } from 'abis/types' +import { GPv2Settlement, Erc20, VCow } from 'abis/types' import GPv2_SETTLEMENT_ABI from 'abis/GPv2Settlement.json' +import V_COW_ABI from 'abis/vCow.json' export * from '@src/hooks/useContract' @@ -26,6 +27,11 @@ export function useGP2SettlementContract(): GPv2Settlement | null { ) } +export function useVCowContract() { + const { chainId } = useActiveWeb3React() + return useContract<VCow>(chainId ? V_COW_CONTRACT_ADDRESS[chainId] : undefined, V_COW_ABI, true) +} + export function useENSRegistrarContract(withSignerIfPossible?: boolean): Contract | null { const { chainId } = useActiveWeb3React() let address: string | undefined diff --git a/src/custom/hooks/useErrorMessageAndModal.tsx b/src/custom/hooks/useErrorMessageAndModal.tsx new file mode 100644 index 000000000..fef383755 --- /dev/null +++ b/src/custom/hooks/useErrorMessageAndModal.tsx @@ -0,0 +1,56 @@ +import { useMemo, useState } from 'react' +import { ErrorMessageProps, SwapCallbackError } from 'components/swap/styleds' +import useTransactionErrorModal from './useTransactionErrorModal' + +/** + * @description hook for getting CowSwap error and handling them visually + * @description ErrorMessage component accepts an error message to override exported error state, and a close option + * @returns returns object: { error, setError, ErrorMessage } => error message, error message setter, and our ErrorMessage component + */ +export function useErrorMessage() { + const [internalError, setError] = useState<string | undefined>() + + return useMemo(() => { + const handleCloseError = () => setError(undefined) + + return { + error: internalError, + handleSetError: setError, + ErrorMessage: ({ + error = internalError, + showClose = false, + ...rest + }: Pick<ErrorMessageProps, 'error' | 'showClose' | '$css'>) => + error ? ( + <SwapCallbackError showClose={showClose} handleClose={handleCloseError} error={error} {...rest} /> + ) : null, + } + }, [internalError]) +} + +export function useErrorModal() { + const [internalError, setInternalError] = useState<string | undefined>() + const { openModal, closeModal, TransactionErrorModal } = useTransactionErrorModal() + + return useMemo(() => { + const handleCloseError = () => { + closeModal() + setInternalError(undefined) + } + const handleSetError = (error: string | undefined) => { + setInternalError(error) + + // IF error, open modal + error && openModal() + } + + return { + error: internalError, + handleCloseError, + handleSetError, + ErrorModal: ({ message = internalError }: { message?: string }) => ( + <TransactionErrorModal onDismiss={handleCloseError} message={message} /> + ), + } + }, [internalError, closeModal, openModal, TransactionErrorModal]) +} diff --git a/src/custom/hooks/useRemainingAllowanceToApprove.ts b/src/custom/hooks/useRemainingAllowanceToApprove.ts new file mode 100644 index 000000000..75add0ad0 --- /dev/null +++ b/src/custom/hooks/useRemainingAllowanceToApprove.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react' +import { Currency, CurrencyAmount } from '@uniswap/sdk-core' +import { useTokenAllowance } from 'hooks/useTokenAllowance' +import { useActiveWeb3React } from 'hooks/web3' + +interface Params { + amountToApprove: CurrencyAmount<Currency> | undefined + spender: string | undefined +} + +/** + * useRemainingAllowanceToApprove + * ====================== + * @description returns allowance of a token against a spender + * and the remaining allowance IF allowance > remaining. Else is null + */ +export default function useRemainingAllowanceToApprove({ amountToApprove, spender }: Params) { + const { account } = useActiveWeb3React() + const allowance = useTokenAllowance(amountToApprove?.wrapped.currency, account ?? undefined, spender) + + return useMemo(() => { + // syntactic sugar - useful in UI to show to users for granularity + // Remaining allowance starts off as undefined - aka 0 + let remainingAllowanceToApprove: CurrencyAmount<Currency> | undefined = undefined + // If amountToApprove is > current allowance, let's return what the difference is + // e.g amountToApprove<100>.minus(currentAllowance<50>) = 50 + // user now only needs to approve 50 + if (allowance && amountToApprove?.greaterThan(allowance)) { + remainingAllowanceToApprove = amountToApprove.subtract(allowance) + } + + return { + allowance, + needsApproval: !allowance || amountToApprove?.greaterThan(allowance), + remainingAllowanceToApprove, + } + }, [allowance, amountToApprove]) +} diff --git a/src/custom/hooks/useTransactionConfirmationModal.tsx b/src/custom/hooks/useTransactionConfirmationModal.tsx new file mode 100644 index 000000000..c3586dfd2 --- /dev/null +++ b/src/custom/hooks/useTransactionConfirmationModal.tsx @@ -0,0 +1,40 @@ +import { useState, useCallback } from 'react' +import TransactionConfirmationModal from 'components/TransactionConfirmationModal' +import { OperationType } from 'components/TransactionConfirmationModal' +import { useOpenModal, useCloseModals, useModalOpen } from 'state/application/hooks' +import { ApplicationModal } from 'state/application/reducer' + +export default function useTransactionConfirmationModal( + defaultOperationType: OperationType = OperationType.WRAP_ETHER +) { + const [operationType, setOperationType] = useState<OperationType>(defaultOperationType) + const [transactionConfirmationModalMsg, setTransactionConfirmationModalMsg] = useState<string>() + const openTransactionConfirmationModalAux = useOpenModal(ApplicationModal.TRANSACTION_CONFIRMATION) + const closeModal = useCloseModals() + const showTransactionConfirmationModal = useModalOpen(ApplicationModal.TRANSACTION_CONFIRMATION) + const openModal = useCallback( + (message: string, operationType: OperationType) => { + setTransactionConfirmationModalMsg(message) + setOperationType(operationType) + openTransactionConfirmationModalAux() + }, + [setTransactionConfirmationModalMsg, openTransactionConfirmationModalAux] + ) + + return { + openModal, + closeModal, + TransactionConfirmationModal: useCallback( + () => ( + <TransactionConfirmationModal + attemptingTxn={true} + isOpen={showTransactionConfirmationModal} + pendingText={transactionConfirmationModalMsg} + onDismiss={closeModal} + operationType={operationType} + /> + ), + [closeModal, operationType, showTransactionConfirmationModal, transactionConfirmationModalMsg] + ), + } +} diff --git a/src/custom/hooks/useTransactionErrorModal.tsx b/src/custom/hooks/useTransactionErrorModal.tsx new file mode 100644 index 000000000..3938bb755 --- /dev/null +++ b/src/custom/hooks/useTransactionErrorModal.tsx @@ -0,0 +1,24 @@ +import { useCallback } from 'react' +import { ApplicationModal } from 'state/application/reducer' +import { TransactionErrorContent } from 'components/TransactionConfirmationModal' +import { useOpenModal, useCloseModals, useModalOpen } from 'state/application/hooks' +import { GpModal } from '../components/Modal' + +export default function useTransactionErrorModal() { + const openModal = useOpenModal(ApplicationModal.TRANSACTION_ERROR) + const closeModal = useCloseModals() + const showTransactionErrorModal = useModalOpen(ApplicationModal.TRANSACTION_ERROR) + + return { + openModal, + closeModal, + TransactionErrorModal: useCallback( + ({ message, onDismiss }: { message?: string; onDismiss: () => void }) => ( + <GpModal isOpen={!!message && showTransactionErrorModal} onDismiss={closeModal}> + <TransactionErrorContent onDismiss={onDismiss} message={message} /> + </GpModal> + ), + [closeModal, showTransactionErrorModal] + ), + } +} diff --git a/src/custom/pages/App/AppMod.tsx b/src/custom/pages/App/AppMod.tsx index 3487e965c..09d9b48e8 100644 --- a/src/custom/pages/App/AppMod.tsx +++ b/src/custom/pages/App/AppMod.tsx @@ -3,14 +3,14 @@ import { Suspense, /* PropsWithChildren, */ ReactNode, useState, useEffect } fro import { Route, Switch, useLocation } from 'react-router-dom' import styled from 'styled-components/macro' import GoogleAnalyticsReporter from 'components/analytics/GoogleAnalyticsReporter' -// import AddressClaimModal from '../components/claim/AddressClaimModal' +import AddressClaimModal from 'components/claim/AddressClaimModal' import ErrorBoundary from 'components/ErrorBoundary' import Header from 'components/Header' import Polling from 'components/Header/Polling' import Popups from 'components/Popups' import Web3ReactManager from 'components/Web3ReactManager' -// import { ApplicationModal } from '../../state/application/reducer' -// import { useModalOpen, useToggleModal } from '../state/application/hooks' +import { ApplicationModal } from 'state/application/reducer' +import { useModalOpen, useToggleModal } from 'state/application/hooks' import DarkModeQueryParamReader from 'theme' /* import AddLiquidity from './AddLiquidity' import { @@ -94,11 +94,11 @@ const Marginer = styled.div` margin-top: 5rem; ` -// function TopLevelModals() { -// const open = useModalOpen(ApplicationModal.ADDRESS_CLAIM) -// const toggle = useToggleModal(ApplicationModal.ADDRESS_CLAIM) -// return <AddressClaimModal isOpen={open} onDismiss={toggle} /> -// } +function TopLevelModals() { + const open = useModalOpen(ApplicationModal.ADDRESS_CLAIM) + const toggle = useToggleModal(ApplicationModal.ADDRESS_CLAIM) + return <AddressClaimModal isOpen={open} onDismiss={toggle} /> +} export default function App(props?: { children?: ReactNode }) { const [bgBlur, setBgBlur] = useState(false) @@ -121,7 +121,7 @@ export default function App(props?: { children?: ReactNode }) { </HeaderWrapper> <BodyWrapper> <Polling /> - {/* <TopLevelModals /> */} + <TopLevelModals /> <ReferralLinkUpdater /> <Switch> {props && props.children} diff --git a/src/custom/pages/App/index.tsx b/src/custom/pages/App/index.tsx index 9f6657fd0..f20f9aacd 100644 --- a/src/custom/pages/App/index.tsx +++ b/src/custom/pages/App/index.tsx @@ -3,6 +3,7 @@ import styled from 'styled-components/macro' import { RedirectPathToSwapOnly, RedirectToSwap } from 'pages/Swap/redirects' import { Route, Switch } from 'react-router-dom' import Swap from 'pages/Swap' +import Claim from 'pages/Claim' import PrivacyPolicy from 'pages/PrivacyPolicy' import CookiePolicy from 'pages/CookiePolicy' import TermsAndConditions from 'pages/TermsAndConditions' @@ -19,6 +20,7 @@ import { version } from '@src/../package.json' import { environmentName } from 'utils/environments' import { useFilterEmptyQueryParams } from 'hooks/useFilterEmptyQueryParams' import RedirectAnySwapAffectedUsers from 'pages/error/AnySwapAffectedUsers/RedirectAnySwapAffectedUsers' +import { IS_CLAIMING_ENABLED } from 'pages/Claim/const' const SENTRY_DSN = process.env.REACT_APP_SENTRY_DSN const SENTRY_TRACES_SAMPLE_RATE = process.env.REACT_APP_SENTRY_TRACES_SAMPLE_RATE @@ -74,6 +76,7 @@ export default function App() { <Route exact strict path="/swap" component={Swap} /> <Route exact strict path="/swap/:outputCurrency" component={RedirectToSwap} /> <Route exact strict path="/send" component={RedirectPathToSwapOnly} /> + {IS_CLAIMING_ENABLED && <Route exact strict path="/claim" component={Claim} />} <Route exact strict path="/about" component={About} /> <Route exact strict path="/profile" component={Profile} /> <Route exact strict path="/faq" component={Faq} /> @@ -83,7 +86,6 @@ export default function App() { <Route exact strict path="/privacy-policy" component={PrivacyPolicy} /> <Route exact strict path="/cookie-policy" component={CookiePolicy} /> <Route exact strict path="/terms-and-conditions" component={TermsAndConditions} /> - <Route exact strict path="/chat" component={createRedirectExternal('https://chat.cowswap.exchange')} /> <Route exact strict path="/docs" component={createRedirectExternal('https://docs.cow.fi')} /> <Route @@ -93,7 +95,6 @@ export default function App() { component={createRedirectExternal('https://dune.xyz/gnosis.protocol/Gnosis-Protocol-V2')} /> <Route exact strict path="/twitter" component={createRedirectExternal('https://twitter.com/MEVprotection')} /> - <Route exact strict path="/" component={RedirectPathToSwapOnly} /> <Route component={NotFound} /> </Switch> diff --git a/src/custom/pages/Claim/CanUserClaimMessage.tsx b/src/custom/pages/Claim/CanUserClaimMessage.tsx new file mode 100644 index 000000000..c78d33114 --- /dev/null +++ b/src/custom/pages/Claim/CanUserClaimMessage.tsx @@ -0,0 +1,52 @@ +import { Trans } from '@lingui/macro' +import { ButtonSecondary } from 'components/Button' +import { IntroDescription } from './styled' +import { ClaimCommonTypes } from './types' +import { useClaimState, useClaimTimeInfo } from 'state/claim/hooks' +import { ClaimStatus } from 'state/claim/actions' +import { formatDateWithTimezone } from 'utils/time' +import useNetworkName from 'hooks/useNetworkName' + +type ClaimIntroductionProps = Pick<ClaimCommonTypes, 'hasClaims' | 'handleChangeAccount'> & { + isAirdropOnly: boolean +} + +export default function CanUserClaimMessage({ hasClaims, isAirdropOnly, handleChangeAccount }: ClaimIntroductionProps) { + const { activeClaimAccount, claimStatus } = useClaimState() + const network = useNetworkName() + + const { airdropDeadline } = useClaimTimeInfo() + + // only show when active claim account + if (!activeClaimAccount || claimStatus !== ClaimStatus.DEFAULT) return null + + if (isAirdropOnly && hasClaims) { + return ( + <IntroDescription> + <p> + <Trans> + Thank you for being a supporter of CowSwap and the CoW protocol. As an important member of the CowSwap + Community you may claim vCOW to be used for voting and governance. You can claim your tokens until{' '} + <i>{formatDateWithTimezone(airdropDeadline)}</i> + </Trans> + </p> + </IntroDescription> + ) + } + + if (!hasClaims) { + return ( + <IntroDescription center> + <Trans> + Unfortunately this account is not eligible for any vCOW claims in {network}. <br /> + <ButtonSecondary onClick={handleChangeAccount} padding="0"> + Try another account + </ButtonSecondary>{' '} + or try in a different network. + </Trans> + </IntroDescription> + ) + } + + return null +} diff --git a/src/custom/pages/Claim/ClaimAddress.tsx b/src/custom/pages/Claim/ClaimAddress.tsx new file mode 100644 index 000000000..4dc07b5f8 --- /dev/null +++ b/src/custom/pages/Claim/ClaimAddress.tsx @@ -0,0 +1,66 @@ +import { useMemo } from 'react' +import { Trans } from '@lingui/macro' +import { ButtonSecondary } from 'components/Button' +import Circle from 'assets/images/blue-loader.svg' +import { CustomLightSpinner, TYPE } from 'theme' +import { CheckAddress, InputField, InputFieldTitle, InputErrorText } from './styled' +import { ClaimCommonTypes } from './types' +import useENS from 'hooks/useENS' +import { useClaimDispatchers, useClaimState } from 'state/claim/hooks' +import { ClaimStatus } from 'state/claim/actions' + +export type ClaimAddressProps = Pick<ClaimCommonTypes, 'account'> & { + toggleWalletModal: () => void +} + +export default function ClaimAddress({ account, toggleWalletModal }: ClaimAddressProps) { + const { activeClaimAccount, claimStatus, inputAddress } = useClaimState() + const { setInputAddress } = useClaimDispatchers() + + const { loading, address: resolvedAddress } = useENS(inputAddress) + + // Show input error + const showInputError = useMemo( + () => Boolean(inputAddress.length > 0 && !loading && !resolvedAddress), + [resolvedAddress, inputAddress, loading] + ) + + const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const input = event.target.value + const withoutSpaces = input.replace(/\s+/g, '') + + setInputAddress(withoutSpaces) + } + + if (activeClaimAccount || claimStatus === ClaimStatus.CONFIRMED) return null + + return ( + <CheckAddress> + <p> + Enter an address to check for any eligible vCOW claims. <br /> + <i>Note: It is possible to claim for an account, using any wallet/account.</i> + {!account && ( + <ButtonSecondary onClick={toggleWalletModal}> + <Trans>or connect a wallet</Trans> + </ButtonSecondary> + )} + </p> + + <InputField> + <InputFieldTitle> + <b>Input address</b> + {loading && <CustomLightSpinner src={Circle} alt="loader" size={'10px'} />} + </InputFieldTitle> + <input placeholder="Address or ENS name" value={inputAddress} onChange={handleInputChange} /> + </InputField> + + {showInputError && ( + <InputErrorText> + <TYPE.error error={true}> + <Trans>Enter valid address or ENS</Trans> + </TYPE.error> + </InputErrorText> + )} + </CheckAddress> + ) +} diff --git a/src/custom/pages/Claim/ClaimNav.tsx b/src/custom/pages/Claim/ClaimNav.tsx new file mode 100644 index 000000000..261495303 --- /dev/null +++ b/src/custom/pages/Claim/ClaimNav.tsx @@ -0,0 +1,46 @@ +import { ButtonSecondary } from 'components/Button' +import { shortenAddress } from 'utils' +import { TopNav, ClaimAccount, ClaimAccountButtons } from './styled' +import { ClaimCommonTypes } from './types' +import { useClaimDispatchers, useClaimState } from 'state/claim/hooks' +import { ClaimStatus } from 'state/claim/actions' +import Identicon from 'components/Identicon' + +type ClaimNavProps = Pick<ClaimCommonTypes, 'account' | 'handleChangeAccount'> + +export default function ClaimNav({ account, handleChangeAccount }: ClaimNavProps) { + const { activeClaimAccount, activeClaimAccountENS, claimStatus, investFlowStep } = useClaimState() + const { setActiveClaimAccount } = useClaimDispatchers() + + const isDefaultStatus = claimStatus === ClaimStatus.DEFAULT + const isConfirmed = claimStatus === ClaimStatus.CONFIRMED + const hasActiveAccount = activeClaimAccount !== '' + const allowToChangeAccount = investFlowStep < 2 && (isDefaultStatus || isConfirmed) + + return ( + <TopNav> + <ClaimAccount> + <div> + {hasActiveAccount && ( + <> + <Identicon account={activeClaimAccount} size={46} /> + <p>{activeClaimAccountENS ? activeClaimAccountENS : shortenAddress(activeClaimAccount)}</p> + </> + )} + </div> + <ClaimAccountButtons> + {allowToChangeAccount && hasActiveAccount ? ( + <ButtonSecondary onClick={handleChangeAccount}>Change account</ButtonSecondary> + ) : ( + !!account && + allowToChangeAccount && ( + <ButtonSecondary onClick={() => setActiveClaimAccount(account)}> + Switch to connected account + </ButtonSecondary> + ) + )} + </ClaimAccountButtons> + </ClaimAccount> + </TopNav> + ) +} diff --git a/src/custom/pages/Claim/ClaimSummary.tsx b/src/custom/pages/Claim/ClaimSummary.tsx new file mode 100644 index 000000000..702f8fed2 --- /dev/null +++ b/src/custom/pages/Claim/ClaimSummary.tsx @@ -0,0 +1,61 @@ +import { Trans } from '@lingui/macro' +import { CurrencyAmount, Currency } from '@uniswap/sdk-core' +import CowProtocolLogo from 'components/CowProtocolLogo' +import { formatMax, formatSmartLocaleAware } from 'utils/format' +import { useClaimState } from 'state/claim/hooks' +import { ClaimSummary as ClaimSummaryWrapper, ClaimSummaryTitle, ClaimTotal } from './styled' +import { ClaimCommonTypes } from './types' +import { ClaimStatus } from 'state/claim/actions' +import { AMOUNT_PRECISION } from 'constants/index' + +type ClaimSummaryProps = Pick<ClaimCommonTypes, 'hasClaims'> & { + unclaimedAmount: ClaimCommonTypes['tokenCurrencyAmount'] | undefined +} + +export function ClaimSummary({ hasClaims, unclaimedAmount }: ClaimSummaryProps) { + const { activeClaimAccount, claimStatus, isInvestFlowActive } = useClaimState() + + const hasClaimSummary = claimStatus === ClaimStatus.DEFAULT && !isInvestFlowActive + + if (!hasClaimSummary) return null + + return ( + <ClaimSummaryView + showClaimText={!activeClaimAccount && !hasClaims} + totalAvailableAmount={(activeClaimAccount && unclaimedAmount) || undefined} + totalAvailableText={'Total available to claim'} + /> + ) +} + +type ClaimSummaryViewProps = { + showClaimText?: boolean + totalAvailableAmount?: CurrencyAmount<Currency> + totalAvailableText?: string +} + +export function ClaimSummaryView({ showClaimText, totalAvailableText, totalAvailableAmount }: ClaimSummaryViewProps) { + return ( + <ClaimSummaryWrapper> + <CowProtocolLogo size={100} /> + {showClaimText && ( + <ClaimSummaryTitle> + <Trans> + Claim <b>vCOW</b> token + </Trans> + </ClaimSummaryTitle> + )} + {totalAvailableAmount && ( + <div> + <ClaimTotal> + {totalAvailableText && <b>{totalAvailableText}</b>} + <p title={`${formatMax(totalAvailableAmount, totalAvailableAmount.currency.decimals)} vCOW`}> + {' '} + {formatSmartLocaleAware(totalAvailableAmount, AMOUNT_PRECISION) || '0'} vCOW + </p> + </ClaimTotal> + </div> + )} + </ClaimSummaryWrapper> + ) +} diff --git a/src/custom/pages/Claim/ClaimingStatus.tsx b/src/custom/pages/Claim/ClaimingStatus.tsx new file mode 100644 index 000000000..967a07bc6 --- /dev/null +++ b/src/custom/pages/Claim/ClaimingStatus.tsx @@ -0,0 +1,103 @@ +import { CurrencyAmount } from '@uniswap/sdk-core' +import { Trans } from '@lingui/macro' +import { ConfirmOrLoadingWrapper, ConfirmedIcon, AttemptFooter, CowSpinner } from 'pages/Claim/styled' +import { ClaimStatus } from 'state/claim/actions' +import { useClaimState } from 'state/claim/hooks' +import { useActiveWeb3React } from 'hooks/web3' +import CowProtocolLogo from 'components/CowProtocolLogo' +import { useAllClaimingTransactions } from 'state/enhancedTransactions/hooks' +import { useMemo } from 'react' +import { Link } from 'react-router-dom' +import { ExplorerLink } from 'components/ExplorerLink' +import { EnhancedTransactionLink } from 'components/EnhancedTransactionLink' +import { ExplorerDataType } from 'utils/getExplorerLink' +import { V_COW } from 'constants/tokens' +import AddToMetamask from 'components/AddToMetamask' +import { formatMax, formatSmartLocaleAware } from 'utils/format' +import { AMOUNT_PRECISION } from 'constants/index' + +export default function ClaimingStatus() { + const { chainId, account } = useActiveWeb3React() + const { activeClaimAccount, claimStatus, claimedAmount } = useClaimState() + + const allClaimTxs = useAllClaimingTransactions() + const lastClaimTx = useMemo(() => { + const numClaims = allClaimTxs.length + return numClaims > 0 ? allClaimTxs[numClaims - 1] : undefined + }, [allClaimTxs]) + + // claim status + const isConfirmed = claimStatus === ClaimStatus.CONFIRMED + const isAttempting = claimStatus === ClaimStatus.ATTEMPTING + const isSubmitted = claimStatus === ClaimStatus.SUBMITTED + const isSelfClaiming = account === activeClaimAccount + + if (!account || !chainId || !activeClaimAccount || claimStatus === ClaimStatus.DEFAULT) return null + + const currency = chainId ? V_COW[chainId] : undefined + + const vCowAmount = currency && CurrencyAmount.fromRawAmount(currency, claimedAmount) + + const formattedVCowAmount = formatSmartLocaleAware(vCowAmount, AMOUNT_PRECISION) + const formattedMaxVCowAmount = vCowAmount?.greaterThan('0') ? formatMax(vCowAmount, currency?.decimals) : '' + + return ( + <ConfirmOrLoadingWrapper activeBG={true}> + <ConfirmedIcon> + {!isConfirmed ? ( + <CowSpinner> + <CowProtocolLogo /> + </CowSpinner> + ) : ( + <CowProtocolLogo size={100} /> + )} + </ConfirmedIcon> + <h3>{isConfirmed ? 'Claimed!' : 'Claiming'}</h3> + {!isConfirmed && ( + <Trans> + <span title={formattedMaxVCowAmount && `${formattedMaxVCowAmount} vCOW`}>{formattedVCowAmount} vCOW</span> + </Trans> + )} + + {isConfirmed && ( + <> + <Trans> + <h3>You have successfully claimed</h3> + </Trans> + <Trans> + <p title={formattedMaxVCowAmount && `${formattedMaxVCowAmount} vCOW`}>{formattedVCowAmount} vCOW</p> + </Trans> + <Trans> + <span role="img" aria-label="party-hat"> + 🎉🐮{' '} + </span> + <p>Welcome to the COWmunnity! :)</p> + </Trans> + {isSelfClaiming ? ( + <Trans> + <p> + You can see your vCOW balance in the <Link to="/profile">Profile</Link> + </p> + <AddToMetamask currency={currency} /> + </Trans> + ) : ( + <Trans> + <p> + You have just claimed on behalf of{' '} + <ExplorerLink id={activeClaimAccount} type={ExplorerDataType.ADDRESS} /> + </p> + </Trans> + )} + </> + )} + {isAttempting && ( + <AttemptFooter> + <p> + <Trans>Confirm this transaction in your wallet</Trans> + </p> + </AttemptFooter> + )} + {isSubmitted && chainId && lastClaimTx?.hash && <EnhancedTransactionLink tx={lastClaimTx} />} + </ConfirmOrLoadingWrapper> + ) +} diff --git a/src/custom/pages/Claim/ClaimsTable.tsx b/src/custom/pages/Claim/ClaimsTable.tsx new file mode 100644 index 000000000..412772293 --- /dev/null +++ b/src/custom/pages/Claim/ClaimsTable.tsx @@ -0,0 +1,197 @@ +import { ClaimType, useClaimDispatchers, useClaimState, useClaimTimeInfo } from 'state/claim/hooks' +import styled from 'styled-components/macro' +import { ClaimTable, ClaimBreakdown, TokenLogo } from 'pages/Claim/styled' +import CowProtocolLogo from 'components/CowProtocolLogo' +import { ClaimStatus } from 'state/claim/actions' +// import { UserClaimDataDetails } from './types' TODO: fix in another PR +import { formatMax, formatSmartLocaleAware } from 'utils/format' +import { EnhancedUserClaimData } from './types' +import { useAllClaimingTransactionIndices } from 'state/enhancedTransactions/hooks' +import { useUserEnhancedClaimData } from 'state/claim/hooks' + +import { CustomLightSpinner } from 'theme' +import Circle from 'assets/images/blue-loader.svg' +import { Countdown } from 'pages/Claim/Countdown' +import { getPaidClaims, getIndexes } from 'state/claim/hooks/utils' +import { useEffect } from 'react' +import { AMOUNT_PRECISION } from 'constants/index' + +export type ClaimsTableProps = { + isAirdropOnly: boolean + hasClaims: boolean +} + +// TODO: fix in other pr +type ClaimsTableRowProps = EnhancedUserClaimData & { + handleSelect: (event: React.ChangeEvent<HTMLInputElement>, index: number) => void + selected: number[] + start: number | null + end: number | null + isPendingClaim: boolean +} + +const ClaimTr = styled.tr<{ isPending?: boolean }>` + > td { + background-color: ${({ isPending }) => (isPending ? '#221954' : 'rgb(255 255 255 / 6%)')}; + cursor: ${({ isPending }) => (isPending ? 'pointer' : 'initial')}; + + &:first-child { + border-radius: 8px 0 0 8px; + } + &:last-child { + border-radius: 0 8px 8px 0; + } + } +` + +const ClaimsTableRow = ({ + index, + type, + isFree, + isPendingClaim, + claimAmount, + currencyAmount, + price, + cost, + handleSelect, + selected, + start, + end, +}: ClaimsTableRowProps) => { + return ( + <ClaimTr key={index} isPending={isPendingClaim}> + <td> + {' '} + <label className="checkAll"> + {isPendingClaim ? ( + <CustomLightSpinner src={Circle} title="Claiming in progress..." alt="loader" size="24px" /> + ) : ( + <input + onChange={(event) => handleSelect(event, index)} + type="checkbox" + name="check" + checked={isFree || selected.includes(index)} + disabled={isFree} + /> + )} + </label> + </td> + <td> + {' '} + {!isFree && <TokenLogo symbol={`${currencyAmount?.currency?.symbol}`} size={34} />} + <CowProtocolLogo size={34} /> + <span> + <b>{isFree ? ClaimType[type] : 'Buy vCOW'}</b> + {!isFree && <i>with {currencyAmount?.currency?.symbol}</i>} + </span> + </td> + <td title={`${formatMax(claimAmount, claimAmount.currency.decimals)} vCOW`}> + {formatSmartLocaleAware(claimAmount, AMOUNT_PRECISION) || 0} vCOW + </td> + <td> + {!isFree || + (price && ( + <span> + Price:{' '} + <b title={formatMax(price)}>{`${formatSmartLocaleAware(price) || 0} vCOW per ${ + currencyAmount?.currency?.symbol + }`}</b> + </span> + ))} + <span> + Cost:{' '} + <b title={cost && `${formatMax(cost, cost.currency.decimals)} ${cost.currency.symbol}`}> + {' '} + {isFree ? ( + <span className="green">Free!</span> + ) : ( + `${formatSmartLocaleAware(cost, AMOUNT_PRECISION) || 0} ${cost?.currency?.symbol}` + )} + </b> + </span> + <span> + Vesting: <b>{type === ClaimType.Airdrop ? 'No' : '4 years (linear)'}</b> + </span> + <span> + Ends in: <b>{start && end && <Countdown start={start} end={end} />}</b> + </span> + </td> + </ClaimTr> + ) +} + +export default function ClaimsTable({ isAirdropOnly, hasClaims }: ClaimsTableProps) { + const { selectedAll, selected, activeClaimAccount, claimStatus, isInvestFlowActive } = useClaimState() + + const { setSelectedAll, setSelected } = useClaimDispatchers() + + const pendingClaimsSet = useAllClaimingTransactionIndices() + + const userClaimData = useUserEnhancedClaimData(activeClaimAccount) + + const { deployment: start, investmentDeadline, airdropDeadline } = useClaimTimeInfo() + + const handleSelect = (event: React.ChangeEvent<HTMLInputElement>, index: number) => { + const checked = event.target.checked + const output = [...selected] + checked ? output.push(index) : output.splice(output.indexOf(index), 1) + setSelected(output) + } + + const handleSelectAll = (event: React.ChangeEvent<HTMLInputElement>) => { + const checked = event.target.checked + const paid = getIndexes(getPaidClaims(userClaimData)) + setSelected(checked ? paid : []) + setSelectedAll(checked) + } + + const paidClaims = getPaidClaims(userClaimData) + + useEffect(() => { + setSelectedAll(selected.length === paidClaims.length) + }, [paidClaims.length, selected.length, setSelectedAll]) + + const showTable = + !isAirdropOnly && hasClaims && activeClaimAccount && claimStatus === ClaimStatus.DEFAULT && !isInvestFlowActive + + if (!showTable) return null + + return ( + <ClaimBreakdown> + <p> + The table overview below represents your current vCOW claiming opportunities. To move forward with one or all of + the options, simply select the row(s) you would like to engage with and move forward via the 'Claim + vCOW' button. + </p> + <ClaimTable> + <table> + <thead> + <tr> + <th> + <label className="checkAll"> + <input checked={selectedAll} onChange={handleSelectAll} type="checkbox" name="check" /> + </label> + </th> + <th>Type of Claim</th> + <th>Amount</th> + <th>Details</th> + </tr> + </thead> + <tbody> + {userClaimData.map((claim: EnhancedUserClaimData) => ( + <ClaimsTableRow + key={claim.index} + {...claim} + isPendingClaim={pendingClaimsSet.has(claim.index)} + selected={selected} + handleSelect={handleSelect} + start={start} + end={claim.isFree ? airdropDeadline : investmentDeadline} + /> + ))} + </tbody> + </table> + </ClaimTable> + </ClaimBreakdown> + ) +} diff --git a/src/custom/pages/Claim/Countdown.tsx b/src/custom/pages/Claim/Countdown.tsx new file mode 100644 index 000000000..74b15ca16 --- /dev/null +++ b/src/custom/pages/Claim/Countdown.tsx @@ -0,0 +1,67 @@ +// Sort of a mod of but not quite from src/pages/Earn/Countdown.tsx +import { useEffect, useState } from 'react' + +const MINUTE = 60 +const HOUR = MINUTE * 60 +const DAY = HOUR * 24 + +export type Props = { + start: number + end: number +} + +/** + * Copied over from src/pages/Earn/Countdown.tsx and heavily modified it + * + * If current time is past end time, returns null + * + * @param start start time in ms + * @param end end time in ms + */ +export function Countdown({ start, end }: Props) { + // get current time, store as seconds because 🤷 + const [time, setTime] = useState(() => Math.floor(Date.now() / 1000)) + + useEffect((): (() => void) | void => { + // we only need to tick if not ended yet + if (time <= end / 1000) { + const timeout = setTimeout(() => setTime(Math.floor(Date.now() / 1000)), 1000) + return () => { + clearTimeout(timeout) + } + } + }, [time, end]) + + const timeUntilGenesis = start / 1000 - time + const timeUntilEnd = end / 1000 - time + + let timeRemaining: number + if (timeUntilGenesis >= 0) { + timeRemaining = timeUntilGenesis + } else { + const ongoing = timeUntilEnd >= 0 + if (ongoing) { + timeRemaining = timeUntilEnd + } else { + timeRemaining = Infinity + } + } + + const days = (timeRemaining - (timeRemaining % DAY)) / DAY + timeRemaining -= days * DAY + const hours = (timeRemaining - (timeRemaining % HOUR)) / HOUR + timeRemaining -= hours * HOUR + const minutes = (timeRemaining - (timeRemaining % MINUTE)) / MINUTE + timeRemaining -= minutes * MINUTE + const seconds = timeRemaining + + return ( + <> + {Number.isFinite(timeRemaining) + ? `${days} days, ${hours.toString().padStart(2, '0')}h, ${minutes.toString().padStart(2, '0')}m, ${seconds + .toString() + .padStart(2, '0')}s` + : 'No longer claimable'} + </> + ) +} diff --git a/src/custom/pages/Claim/EligibleBanner.tsx b/src/custom/pages/Claim/EligibleBanner.tsx new file mode 100644 index 000000000..91fa21a01 --- /dev/null +++ b/src/custom/pages/Claim/EligibleBanner.tsx @@ -0,0 +1,20 @@ +import { Trans } from '@lingui/macro' +import { useClaimState } from 'state/claim/hooks' +import { EligibleBanner as EligibleBannerWrapper } from './styled' +import CheckCircle from 'assets/cow-swap/check.svg' +import { ClaimStatus } from 'state/claim/actions' + +export default function EligibleBanner({ hasClaims }: { hasClaims: boolean }) { + const { claimStatus, activeClaimAccount, isInvestFlowActive } = useClaimState() + + const isEligible = claimStatus === ClaimStatus.DEFAULT && !!activeClaimAccount && !isInvestFlowActive && hasClaims + + if (!isEligible) return null + + return ( + <EligibleBannerWrapper> + <img src={CheckCircle} alt="eligible" /> + <Trans>This account is eligible for vCOW token claims!</Trans> + </EligibleBannerWrapper> + ) +} diff --git a/src/custom/pages/Claim/FooterNavButtons.tsx b/src/custom/pages/Claim/FooterNavButtons.tsx new file mode 100644 index 000000000..6870ebd85 --- /dev/null +++ b/src/custom/pages/Claim/FooterNavButtons.tsx @@ -0,0 +1,129 @@ +import { Trans } from '@lingui/macro' +import { isAddress } from '@ethersproject/address' +import { useClaimDispatchers, useClaimState, useHasClaimInvestmentFlowError } from 'state/claim/hooks' +import { ButtonPrimary, ButtonSecondary } from 'components/Button' +import { ClaimStatus } from 'state/claim/actions' +import { FooterNavButtons as FooterNavButtonsWrapper, ReadMoreText } from './styled' +import { useActiveWeb3React } from 'hooks/web3' +import { ClaimsTableProps } from './ClaimsTable' +import { ClaimAddressProps } from './ClaimAddress' +import { ReactNode } from 'react' +import { ExternalLink } from 'theme/index' +import { COW_LINKS } from '.' + +type FooterNavButtonsProps = Pick<ClaimsTableProps, 'hasClaims' | 'isAirdropOnly'> & + Pick<ClaimAddressProps, 'toggleWalletModal'> & { + isPaidClaimsOnly: boolean + resolvedAddress: string | null + handleSubmitClaim: () => void + handleCheckClaim: () => void + } + +function ReadMore() { + return ( + <ReadMoreText> + <ExternalLink href={COW_LINKS.vCowPost}>Read more about vCOW</ExternalLink> + </ReadMoreText> + ) +} + +export default function FooterNavButtons({ + hasClaims, + isAirdropOnly, + isPaidClaimsOnly, + resolvedAddress, + toggleWalletModal, + handleSubmitClaim, + handleCheckClaim, +}: FooterNavButtonsProps) { + const { account } = useActiveWeb3React() + const { + // account + activeClaimAccount, + // claiming + claimStatus, + // investment + investFlowStep, + isInvestFlowActive, + // table select change + selected, + } = useClaimState() + + const { + // investing + setInvestFlowStep, + setIsInvestFlowActive, + } = useClaimDispatchers() + + const hasError = useHasClaimInvestmentFlowError() + + const isInputAddressValid = isAddress(resolvedAddress || '') + + // User is connected and has some unclaimed claims + const isConnectedAndHasClaims = account && activeClaimAccount && hasClaims && claimStatus === ClaimStatus.DEFAULT + const noPaidClaimsSelected = !selected.length + + let buttonContent: ReactNode = null + // Disconnected, show wallet connect + if (!account) { + buttonContent = ( + <ButtonPrimary onClick={toggleWalletModal}> + <Trans>Connect a wallet</Trans> + </ButtonPrimary> + ) + } + + // User has no set active claim account and/or has claims, show claim account search + if ((!activeClaimAccount || !hasClaims) && claimStatus === ClaimStatus.DEFAULT) { + buttonContent = ( + <> + <ButtonPrimary disabled={!isInputAddressValid} type="text" onClick={handleCheckClaim}> + <Trans>Check claimable vCOW</Trans> + </ButtonPrimary> + <ReadMore /> + </> + ) + } + + // USER is CONNECTED + HAS SOMETHING TO CLAIM + if (isConnectedAndHasClaims) { + if (!isInvestFlowActive) { + buttonContent = ( + <> + <ButtonPrimary onClick={handleSubmitClaim} disabled={isPaidClaimsOnly && noPaidClaimsSelected}> + <Trans>Claim vCOW</Trans> + </ButtonPrimary> + <ReadMore /> + </> + ) + } else if (!isAirdropOnly) { + buttonContent = ( + <> + {investFlowStep === 0 ? ( + <ButtonPrimary onClick={() => setInvestFlowStep(1)}> + <Trans>Continue</Trans> + </ButtonPrimary> + ) : investFlowStep === 1 ? ( + <ButtonPrimary onClick={() => setInvestFlowStep(2)} disabled={hasError}> + <Trans>Review</Trans> + </ButtonPrimary> + ) : ( + <ButtonPrimary onClick={handleSubmitClaim}> + <Trans>Claim and invest vCOW</Trans> + </ButtonPrimary> + )} + + <ButtonSecondary + onClick={() => + investFlowStep === 0 ? setIsInvestFlowActive(false) : setInvestFlowStep(investFlowStep - 1) + } + > + <Trans>Go back</Trans> + </ButtonSecondary> + </> + ) + } + } + + return <FooterNavButtonsWrapper>{buttonContent}</FooterNavButtonsWrapper> +} diff --git a/src/custom/pages/Claim/InvestmentFlow/InvestOption.tsx b/src/custom/pages/Claim/InvestmentFlow/InvestOption.tsx new file mode 100644 index 000000000..ba3385a7a --- /dev/null +++ b/src/custom/pages/Claim/InvestmentFlow/InvestOption.tsx @@ -0,0 +1,381 @@ +import { useCallback, useMemo, useState, useEffect } from 'react' +import { CurrencyAmount, Percent } from '@uniswap/sdk-core' +import { BigNumber } from '@ethersproject/bignumber' + +import CowProtocolLogo from 'components/CowProtocolLogo' +import { InvestTokenGroup, TokenLogo, InvestSummary, InvestInput, InvestAvailableBar, UnderlineButton } from '../styled' +import { formatMax, formatSmartLocaleAware } from 'utils/format' +import Row from 'components/Row' +import CheckCircle from 'assets/cow-swap/check.svg' +import { InvestmentFlowProps } from '.' +import { ApprovalState, useApproveCallbackFromClaim } from 'hooks/useApproveCallback' +import { useCurrencyBalance } from 'state/wallet/hooks' +import { useActiveWeb3React } from 'hooks/web3' +import { useClaimDispatchers, useClaimState } from 'state/claim/hooks' +import { StyledNumericalInput } from 'components/CurrencyInputPanel/CurrencyInputPanelMod' + +import { ButtonConfirmed } from 'components/Button' +import { ButtonSize } from 'theme' +import Loader from 'components/Loader' +import { useErrorModal } from 'hooks/useErrorMessageAndModal' +import { tryParseAmount } from 'state/swap/hooks' +import { calculateInvestmentAmounts, calculatePercentage } from 'state/claim/hooks/utils' +import { AMOUNT_PRECISION, PERCENTAGE_PRECISION } from 'constants/index' +import { useGasPrices } from 'state/gas/hooks' +import { AVG_APPROVE_COST_GWEI } from 'components/swap/EthWethWrap/helpers' +import { EnhancedUserClaimData } from '../types' +import { OperationType } from 'components/TransactionConfirmationModal' + +const ErrorMsgs = { + InsufficientBalance: (symbol = '') => `Insufficient ${symbol} balance to cover investment amount`, + OverMaxInvestment: `Your investment amount can't be above the maximum investment allowed`, + InvestmentIsZero: `Your investment amount can't be zero`, + NotApproved: (symbol = '') => `Please approve ${symbol} token`, + InsufficientNativeBalance: (symbol = '', amount = '') => + `You might not have enough ${symbol} to pay for the network transaction fee (estimated ${amount} ${symbol})`, +} + +type InvestOptionProps = { + claim: EnhancedUserClaimData + optionIndex: number + openModal: InvestmentFlowProps['modalCbs']['openModal'] + closeModal: InvestmentFlowProps['modalCbs']['closeModal'] +} + +export default function InvestOption({ claim, optionIndex, openModal, closeModal }: InvestOptionProps) { + const { currencyAmount, price, cost: maxCost } = claim + + const { account, chainId } = useActiveWeb3React() + const { updateInvestAmount, updateInvestError } = useClaimDispatchers() + const { investFlowData, activeClaimAccount, estimatedGas } = useClaimState() + + const investmentAmount = investFlowData[optionIndex].investedAmount + + // Approve hooks + const { + approvalState: approveState, + approve: approveCallback, + // revokeApprove: revokeApprovalCallback, // CURRENTLY TEST ONLY + // isPendingApproval, // CURRENTLY TEST ONLY + } = useApproveCallbackFromClaim({ + openTransactionConfirmationModal: (message: string, operationType: OperationType) => + openModal(message, operationType), + closeModals: closeModal, + claim, + }) + + const isEtherApproveState = approveState === ApprovalState.UNKNOWN + + const { handleSetError, handleCloseError, ErrorModal } = useErrorModal() + + const [percentage, setPercentage] = useState<string>('0') + const [typedValue, setTypedValue] = useState<string>('') + const [inputWarning, setInputWarning] = useState<string>('') + + const investedAmount = investFlowData[optionIndex].investedAmount + const inputError = investFlowData[optionIndex].error + + // Syntactic sugar fns for setting/resetting global state + const setInvestedAmount = useCallback( + (amount: string) => updateInvestAmount({ index: optionIndex, amount }), + [optionIndex, updateInvestAmount] + ) + const setInputError = useCallback( + (error: string) => updateInvestError({ index: optionIndex, error }), + [optionIndex, updateInvestError] + ) + const resetInputError = useCallback( + () => updateInvestError({ index: optionIndex, error: undefined }), + [optionIndex, updateInvestError] + ) + + const token = currencyAmount?.currency + const isNative = token?.isNative + const balance = useCurrencyBalance(account || undefined, token) + + const gasPrice = useGasPrices(isNative ? chainId : undefined) + + const isSelfClaiming = account === activeClaimAccount + const noBalance = !balance || balance.equalTo('0') + + const isApproved = approveState === ApprovalState.APPROVED + + const gasCost = useMemo(() => { + if (!estimatedGas || !isNative) { + return + } + + // Based on how much gas will be used (estimatedGas) and current gas prices (if available) + // calculate how much that would cost in native currency. + // We pick `fast` to be conservative. Also, it's non-blocking, so the user is aware but can proceed + const amount = BigNumber.from(estimatedGas).mul(gasPrice?.fast || AVG_APPROVE_COST_GWEI) + + return CurrencyAmount.fromRawAmount(token, amount.toString()) + }, [estimatedGas, gasPrice?.fast, isNative, token]) + + // on invest max amount click handler + const setMaxAmount = useCallback(() => { + if (!maxCost || noBalance) { + return + } + + const value = maxCost.greaterThan(balance) ? balance : maxCost + setTypedValue(value.toExact() || '') + }, [balance, maxCost, noBalance]) + + // Save "local" approving state (pre-BC) for rendering spinners etc + const [approving, setApproving] = useState(false) + const handleApprove = useCallback(async () => { + // reset errors and close any modals + handleCloseError() + + if (!approveCallback) return + + try { + setApproving(true) + const summary = `Approve ${token?.symbol || 'token'} for investing in vCOW` + await approveCallback({ modalMessage: summary, transactionSummary: summary }) + } catch (error) { + console.error('[InvestOption]: Issue approving.', error) + handleSetError(error?.message) + } finally { + setApproving(false) + } + }, [approveCallback, handleCloseError, handleSetError, token?.symbol]) + + /* // CURRENTLY TEST ONLY + const handleRevokeApproval = useCallback(async () => { + // reset errors and close any modals + handleCloseError() + + if (!revokeApprovalCallback) return + + try { + setApproving(true) + const summary = `Revoke ${token?.symbol || 'token'} approval for vCOW contract` + await revokeApprovalCallback({ + modalMessage: summary, + transactionSummary: summary, + }) + } catch (error) { + console.error('[InvestOption]: Issue revoking approval.', error) + handleSetError(error?.message) + } finally { + setApproving(false) + } + }, [handleCloseError, handleSetError, revokeApprovalCallback, token?.symbol]) + */ + + const vCowAmount = useMemo( + () => calculateInvestmentAmounts(claim, investmentAmount)?.vCowAmount, + [claim, investmentAmount] + ) + + // if there is investmentAmount in redux state for this option set it as typedValue + useEffect(() => { + const { investmentCost } = calculateInvestmentAmounts(claim, investedAmount) + + if (!investmentCost) { + return + } + + if (!investmentCost?.equalTo(0)) { + setTypedValue(investmentCost?.toExact()) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // if we are claiming for someone else we will set values to max + useEffect(() => { + if (!balance || !maxCost) { + return + } + + if (!isSelfClaiming && !balance.lessThan(maxCost)) { + setMaxAmount() + } + }, [balance, isSelfClaiming, maxCost, setMaxAmount]) + + // handle input value change + useEffect(() => { + let error = null + let warning + + const parsedAmount = tryParseAmount(typedValue, token) + + if (!maxCost || !balance) { + return + } + + // set different errors in order of importance + if (balance.lessThan(maxCost) && !isSelfClaiming) { + error = ErrorMsgs.InsufficientBalance(token?.symbol) + } else if (!isNative && !isApproved) { + error = ErrorMsgs.NotApproved(token?.symbol) + } else if (!parsedAmount) { + error = ErrorMsgs.InvestmentIsZero + } else if (parsedAmount.greaterThan(maxCost)) { + error = ErrorMsgs.OverMaxInvestment + } else if (parsedAmount.greaterThan(balance)) { + error = ErrorMsgs.InsufficientBalance(token?.symbol) + } else if (isNative && gasCost && parsedAmount.add(gasCost).greaterThan(balance)) { + warning = ErrorMsgs.InsufficientNativeBalance(token?.symbol, formatSmartLocaleAware(gasCost)) + } + setInputWarning(warning || '') + + if (error) { + // if there is error set it in redux + setInputError(error) + setPercentage('0') + } else { + if (!parsedAmount) { + return + } + // basically the magic happens in this block + + // update redux state to remove error for this field + resetInputError() + + // update redux state with new investAmount value + setInvestedAmount(parsedAmount.quotient.toString()) + + // update the local state with percentage value + setPercentage(_formatPercentage(calculatePercentage(parsedAmount, maxCost))) + } + }, [ + balance, + typedValue, + isSelfClaiming, + token, + isNative, + isApproved, + maxCost, + setInputError, + resetInputError, + setInvestedAmount, + gasCost, + ]) + + return ( + <InvestTokenGroup> + <div> + <h3>Buy vCOW with {currencyAmount?.currency?.symbol}</h3> + <span> + <TokenLogo symbol={currencyAmount?.currency?.symbol || '-'} size={72} /> + <CowProtocolLogo size={72} /> + </span> + </div> + + <span> + <InvestSummary> + <span> + <b>Price</b>{' '} + <i title={formatMax(price)}> + {formatSmartLocaleAware(price) || '0'} vCOW per {currencyAmount?.currency?.symbol} + </i> + </span> + + <span> + <b>Max. investment available</b>{' '} + <i title={maxCost && `${formatMax(maxCost, maxCost.currency.decimals)} ${maxCost.currency.symbol}`}> + {formatSmartLocaleAware(maxCost, AMOUNT_PRECISION) || '0'} {maxCost?.currency?.symbol} + </i> + </span> + + <span> + <b>Token approval</b> + {!isEtherApproveState ? ( + <i> + {approveState !== ApprovalState.APPROVED ? ( + `${currencyAmount?.currency?.symbol} not approved` + ) : ( + <Row> + <span>{currencyAmount?.currency?.symbol} approved</span> + <img src={CheckCircle} alt="Approved" /> + </Row> + )} + </i> + ) : ( + <i> + <Row> + <span>Approval not required!</span> + <img src={CheckCircle} alt="Approved" /> + </Row> + </i> + )} + {/* Token Approve buton - not shown for ETH */} + {!isEtherApproveState && approveState !== ApprovalState.APPROVED && ( + <ButtonConfirmed + buttonSize={ButtonSize.SMALL} + onClick={handleApprove} + disabled={ + approving || approveState === ApprovalState.PENDING || approveState !== ApprovalState.NOT_APPROVED + } + altDisabledStyle={approveState === ApprovalState.PENDING} // show solid button while waiting + > + {approving || approveState === ApprovalState.PENDING ? ( + <Loader stroke="white" /> + ) : ( + <span>Approve {currencyAmount?.currency?.symbol}</span> + )} + </ButtonConfirmed> + )} + {/* + // CURRENTLY TEST ONLY + approveState === ApprovalState.APPROVED && ( + <UnderlineButton disabled={approving || isPendingApproval} onClick={handleRevokeApproval}> + Revoke approval {approving || (isPendingApproval && <Loader size="12px" stroke="white" />)} + </UnderlineButton> + ) + */} + </span> + + <span> + <b>Available investment used</b> + <InvestAvailableBar percentage={Number(percentage)} /> + </span> + </InvestSummary> + {/* Error modal */} + <ErrorModal /> + {/* Investment inputs */} + <InvestInput> + <div> + <label> + <span> + <b>Balance:</b> + <i title={balance && `${formatMax(balance, balance.currency.decimals)} ${balance.currency.symbol}`}> + {formatSmartLocaleAware(balance, AMOUNT_PRECISION) || 0} {balance?.currency?.symbol} + </i> + {/* Button should use the max possible amount the user can invest, considering their balance + max investment allowed */} + {!noBalance && isSelfClaiming && ( + <UnderlineButton disabled={!isSelfClaiming} onClick={setMaxAmount}> + {' '} + (invest max. possible) + </UnderlineButton> + )} + </span> + <StyledNumericalInput + onUserInput={setTypedValue} + disabled={noBalance || !isSelfClaiming} + placeholder="0" + $loading={false} + value={typedValue} + /> + <b>{currencyAmount?.currency?.symbol}</b> + </label> + <i title={vCowAmount && `${formatMax(vCowAmount, vCowAmount.currency.decimals)} vCOW`}> + Receive: {formatSmartLocaleAware(vCowAmount, AMOUNT_PRECISION) || 0} vCOW + </i> + {/* Insufficient balance validation error */} + {inputError && <small>{inputError}</small>} + {inputWarning && <small className="warn">{inputWarning}</small>} + </div> + </InvestInput> + </span> + </InvestTokenGroup> + ) +} + +function _formatPercentage(percentage: Percent): string { + return formatSmartLocaleAware(percentage, PERCENTAGE_PRECISION) || '0' +} diff --git a/src/custom/pages/Claim/InvestmentFlow/InvestSummaryRow.tsx b/src/custom/pages/Claim/InvestmentFlow/InvestSummaryRow.tsx new file mode 100644 index 000000000..21fb641dc --- /dev/null +++ b/src/custom/pages/Claim/InvestmentFlow/InvestSummaryRow.tsx @@ -0,0 +1,88 @@ +import { ClaimType } from 'state/claim/hooks' +import { calculatePercentage } from 'state/claim/hooks/utils' +import { TokenLogo, InvestAvailableBar } from 'pages/Claim/styled' +import { ClaimWithInvestmentData } from 'pages/Claim/types' +import CowProtocolLogo from 'components/CowProtocolLogo' +import { formatMax, formatSmartLocaleAware } from 'utils/format' +import { ONE_HUNDRED_PERCENT } from 'constants/misc' +import { AMOUNT_PRECISION } from 'constants/index' + +export type Props = { claim: ClaimWithInvestmentData } + +export function InvestSummaryRow(props: Props): JSX.Element | null { + const { claim } = props + + const { isFree, type, price, currencyAmount, vCowAmount, cost, investmentCost } = claim + + const symbol = isFree ? '' : (currencyAmount?.currency?.symbol as string) + + const formattedCost = formatSmartLocaleAware(investmentCost, AMOUNT_PRECISION) || '0' + const formattedCostMaxPrecision = investmentCost + ? `${formatMax(investmentCost, currencyAmount?.currency?.decimals)} ${symbol}` + : '' + + const percentage = investmentCost && cost && calculatePercentage(investmentCost, cost) + + return ( + <tr> + <td> + {isFree ? ( + <> + <CowProtocolLogo size={42} /> + <span> + <b>{ClaimType[type]}</b> + </span> + </> + ) : ( + <> + <TokenLogo symbol={symbol} size={42} /> + <CowProtocolLogo size={42} /> + <span> + <b>Buy vCOW</b> + <i>with {symbol}</i> + </span> + </> + )} + </td> + + <td> + <i title={`${formatMax(vCowAmount, vCowAmount?.currency.decimals)} vCOW`}> + {formatSmartLocaleAware(vCowAmount, AMOUNT_PRECISION) || '0'} vCOW + </i> + + {!isFree && ( + <span> + <b>Investment amount:</b>{' '} + <i title={formattedCostMaxPrecision}> + {formattedCost} {symbol} + </i> + <InvestAvailableBar percentage={Number(percentage?.toFixed(2))} /> + {percentage?.lessThan(ONE_HUNDRED_PERCENT) && ( + <small> + Note: You will <b>not be able</b> to invest anymore after claiming. + </small> + )} + </span> + )} + </td> + + <td> + {!isFree && ( + <span> + <b>Price:</b>{' '} + <i title={formatMax(price)}> + {formatSmartLocaleAware(price) || '0'} vCOW per {symbol} + </i> + </span> + )} + <span> + <b>Cost:</b> <i title={formattedCostMaxPrecision}>{isFree ? 'Free!' : `${formattedCost} ${symbol}`}</i> + </span> + <span> + <b>Vesting:</b> + <i>{type === ClaimType.Airdrop ? 'No' : '4 years (linear)'}</i> + </span> + </td> + </tr> + ) +} diff --git a/src/custom/pages/Claim/InvestmentFlow/index.tsx b/src/custom/pages/Claim/InvestmentFlow/index.tsx new file mode 100644 index 000000000..95f31e046 --- /dev/null +++ b/src/custom/pages/Claim/InvestmentFlow/index.tsx @@ -0,0 +1,271 @@ +import { useEffect, useMemo } from 'react' +import { CurrencyAmount } from '@uniswap/sdk-core' + +import { + InvestFlow, + InvestContent, + InvestFlowValidation, + InvestSummaryTable, + ClaimTable, + AccountClaimSummary, + Badge, +} from 'pages/Claim/styled' +import { InvestSummaryRow } from 'pages/Claim/InvestmentFlow/InvestSummaryRow' +import { ClaimSummaryView } from 'pages/Claim/ClaimSummary' + +import { Stepper } from 'components/Stepper' + +import { + useClaimState, + useUserEnhancedClaimData, + useClaimDispatchers, + useHasClaimInvestmentFlowError, +} from 'state/claim/hooks' +import { ClaimStatus } from 'state/claim/actions' +import { InvestClaim } from 'state/claim/reducer' +import { calculateInvestmentAmounts } from 'state/claim/hooks/utils' + +import { useActiveWeb3React } from 'hooks/web3' + +import InvestOption from './InvestOption' +import { ClaimCommonTypes, ClaimWithInvestmentData, EnhancedUserClaimData } from '../types' +import { COW_LINKS } from 'pages/Claim' +import { ExternalLink } from 'theme' +import { ExplorerLink } from 'components/ExplorerLink' +import { ExplorerDataType } from 'utils/getExplorerLink' + +import { BadgeVariant } from 'components/Badge' +import { DollarSign, Icon, Send } from 'react-feather' +import { OperationType } from 'components/TransactionConfirmationModal' + +const STEPS_DATA = [ + { + title: 'Start', + }, + { + title: 'Set allowances', + subtitle: 'Approve all tokens to be used for investment.', + }, + { + title: 'Submit claim', + subtitle: 'Submit and confirm the transaction to claim vCOW.', + }, +] + +export type InvestmentFlowProps = Pick<ClaimCommonTypes, 'hasClaims'> & { + isAirdropOnly: boolean + modalCbs: { + openModal: (message: string, operationType: OperationType) => void + closeModal: () => void + } +} + +function _classifyAndFilterClaimData(claimData: EnhancedUserClaimData[], selected: number[]) { + const paid: EnhancedUserClaimData[] = [] + const free: EnhancedUserClaimData[] = [] + + claimData.forEach((claim) => { + if (claim.isFree) { + free.push(claim) + } else if (selected.includes(claim.index)) { + paid.push(claim) + } + }) + return [free, paid] +} + +function _enhancedUserClaimToClaimWithInvestment( + claim: EnhancedUserClaimData, + investFlowData: InvestClaim[] +): ClaimWithInvestmentData { + const investmentAmount = claim.isFree + ? undefined + : investFlowData.find(({ index }) => index === claim.index)?.investedAmount + + return { ...claim, ...calculateInvestmentAmounts(claim, investmentAmount) } +} + +function _calculateTotalVCow(allClaims: ClaimWithInvestmentData[]) { + // Re-use the vCow instance, if there's any claim at all + const zeroVCow = allClaims[0] && CurrencyAmount.fromRawAmount(allClaims[0].claimAmount.currency, '0') + + if (!zeroVCow) { + return + } + + // Sum up all the vCowAmount being claimed + return allClaims.reduce<typeof zeroVCow>( + (total, { vCowAmount }) => total.add(vCowAmount?.wrapped || zeroVCow), + zeroVCow + ) +} + +type AccountDetailsProps = { + label: string + account: string + connectedAccount: string + Icon: Icon +} + +function AccountDetails({ label, account, connectedAccount, Icon }: AccountDetailsProps) { + return ( + <span> + <b> + <Icon width={14} height={14} /> {label}: + </b> + <i> + <ExplorerLink id={account} label={account} type={ExplorerDataType.ADDRESS} />{' '} + {account === connectedAccount ? ( + <Badge variant={BadgeVariant.POSITIVE}>  Connected account</Badge> + ) : ( + <Badge variant={BadgeVariant.WARNING}>  External account</Badge> + )} + </i> + </span> + ) +} + +export default function InvestmentFlow({ hasClaims, isAirdropOnly, modalCbs }: InvestmentFlowProps) { + const { account } = useActiveWeb3React() + const { selected, activeClaimAccount, claimStatus, isInvestFlowActive, investFlowStep, investFlowData } = + useClaimState() + const { initInvestFlowData } = useClaimDispatchers() + const claimData = useUserEnhancedClaimData(activeClaimAccount) + + const hasError = useHasClaimInvestmentFlowError() + + // Filtering and splitting claims into free and selected paid claims + // `selectedClaims` are used on step 1 and 2 + // `freeClaims` are used on step 2 + const [freeClaims, selectedClaims] = useMemo( + () => _classifyAndFilterClaimData(claimData, selected), + [claimData, selected] + ) + + // Merge all claims together again, with their investment data for step 2 + const allClaims: ClaimWithInvestmentData[] = useMemo( + () => + freeClaims.concat(selectedClaims).map((claim) => _enhancedUserClaimToClaimWithInvestment(claim, investFlowData)), + [freeClaims, investFlowData, selectedClaims] + ) + const totalVCow = useMemo(() => _calculateTotalVCow(allClaims), [allClaims]) + + useEffect(() => { + initInvestFlowData() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isInvestFlowActive]) + + if ( + !account || // no connected account + !activeClaimAccount || // no selected account for claiming + !hasClaims || // no claims + !isInvestFlowActive || // not on correct step (account change in mid-step) + claimStatus !== ClaimStatus.DEFAULT || // not in default claim state + isAirdropOnly // is only for airdrop + ) { + return null + } + + return ( + <InvestFlow> + <Stepper steps={STEPS_DATA} activeStep={investFlowStep} /> + + <h1> + {investFlowStep === 0 + ? 'Claim and invest' + : investFlowStep === 1 + ? 'Set allowance to Buy vCOW' + : 'Confirm transaction to claim all vCOW'} + </h1> + + {investFlowStep === 0 && ( + <p> + You have chosen to exercise one or more investment opportunities alongside claiming your airdrop. Exercising + your investment options will give you the chance to acquire vCOW tokens at a fixed price. This process + consists of two steps: + <br /> + <br /> + 1) Define the amount you would like to invest and set the required allowances for the token you are purchasing + vCOW with. + <br /> + <br /> + 2) Claim your vCOW tokens for the Airdrop (available immediately) and for your investment (vesting linearly + over 4 years). + <br /> + <br /> + For more details around the token, please read{' '} + <ExternalLink href={COW_LINKS.vCowPost}>the blog post</ExternalLink> + .<br /> For more details about the claiming process, please read{' '} + <ExternalLink href={COW_LINKS.stepGuide}>step by step guide</ExternalLink>. + </p> + )} + + {/* Invest flow: Step 1 > Set allowances and investment amounts */} + {investFlowStep === 1 ? ( + <InvestContent> + <p> + Your account can participate in the investment of vCOW. Each investment opportunity will allow you to invest + up to a predefined maximum amount of tokens{' '} + </p> + + {selectedClaims.map((claim, index) => ( + <InvestOption key={claim.index} optionIndex={index} claim={claim} {...modalCbs} /> + ))} + + {hasError && <InvestFlowValidation>Fix the errors before continuing</InvestFlowValidation>} + </InvestContent> + ) : null} + {/* Invest flow: Step 2 > Review summary */} + {investFlowStep === 2 ? ( + <InvestContent> + <ClaimSummaryView totalAvailableAmount={totalVCow} totalAvailableText={'Total amount to claim'} /> + <ClaimTable> + <InvestSummaryTable> + <thead> + <tr> + <th>Claim type</th> + <th>Amount to receive</th> + <th>Details</th> + </tr> + </thead> + <tbody> + {allClaims.map((claim) => ( + <InvestSummaryRow claim={claim} key={claim.index} /> + ))} + </tbody> + </InvestSummaryTable> + </ClaimTable> + + <AccountClaimSummary> + <AccountDetails + label="Claiming with account" + account={account} + connectedAccount={account} + Icon={DollarSign} + /> + <AccountDetails + label="Receiving account" + account={activeClaimAccount} + connectedAccount={account} + Icon={Send} + /> + </AccountClaimSummary> + + <h4>Ready to claim your vCOW?</h4> + <p> + <b>What will happen?</b> By sending this Ethereum transaction, you will be investing tokens from the + connected account and exchanging them for vCOW tokens that will be received by the claiming account + specified above. + </p> + <p> + <b>Can I modify the invested amounts or invest partial amounts later?</b> No. Once you send the transaction, + you cannot increase or reduce the investment. Investment opportunities can only be exercised once. + </p> + <p> + <b>Important!</b> Please make sure you intend to claim and send vCOW to the mentioned receiving account(s) + </p> + </InvestContent> + ) : null} + </InvestFlow> + ) +} diff --git a/src/custom/pages/Claim/const.ts b/src/custom/pages/Claim/const.ts new file mode 100644 index 000000000..7b64e701a --- /dev/null +++ b/src/custom/pages/Claim/const.ts @@ -0,0 +1,3 @@ +import { isProd, isEns, isBarn } from 'utils/environments' + +export const IS_CLAIMING_ENABLED = !isProd && !isEns && !isBarn diff --git a/src/custom/pages/Claim/index.tsx b/src/custom/pages/Claim/index.tsx new file mode 100644 index 000000000..146432ca7 --- /dev/null +++ b/src/custom/pages/Claim/index.tsx @@ -0,0 +1,226 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { useActiveWeb3React } from 'hooks/web3' +import { useUserEnhancedClaimData, useUserUnclaimedAmount, useClaimCallback, ClaimInput } from 'state/claim/hooks' +import { PageWrapper } from 'pages/Claim/styled' +import EligibleBanner from './EligibleBanner' +import { getFreeClaims, hasPaidClaim, hasFreeClaim, prepareInvestClaims } from 'state/claim/hooks/utils' +import { useWalletModalToggle } from 'state/application/hooks' +import Confetti from 'components/Confetti' + +import useENS from 'hooks/useENS' + +import ClaimNav from './ClaimNav' +import { ClaimSummary } from './ClaimSummary' +import ClaimAddress from './ClaimAddress' +import CanUserClaimMessage from './CanUserClaimMessage' +import ClaimingStatus from './ClaimingStatus' +import ClaimsTable from './ClaimsTable' +import InvestmentFlow from './InvestmentFlow' + +import { useClaimDispatchers, useClaimState } from 'state/claim/hooks' +import { ClaimStatus } from 'state/claim/actions' + +import { OperationType } from 'components/TransactionConfirmationModal' +import useTransactionConfirmationModal from 'hooks/useTransactionConfirmationModal' + +import { useErrorModal } from 'hooks/useErrorMessageAndModal' +import FooterNavButtons from './FooterNavButtons' + +/* TODO: Replace URLs with the actual final URL destinations */ +export const COW_LINKS = { + vCowPost: 'https://cow.fi/', + stepGuide: 'https://cow.fi/', +} + +export default function Claim() { + const { account } = useActiveWeb3React() + + const { + // address/ENS address + inputAddress, + // account + activeClaimAccount, + // check address + isSearchUsed, + // claiming + claimStatus, + // investment + investFlowStep, + // table select change + selected, + // investFlowData + investFlowData, + } = useClaimState() + + const { + // account + setInputAddress, + setActiveClaimAccount, + setActiveClaimAccountENS, + // search + setIsSearchUsed, + // claiming + setClaimStatus, + setClaimedAmount, + setEstimatedGas, + // investing + setIsInvestFlowActive, + // claim row selection + setSelected, + // reset claim ui + resetClaimUi, + } = useClaimDispatchers() + + // addresses + const { address: resolvedAddress, name: resolvedENS } = useENS(inputAddress) + + // toggle wallet when disconnected + const toggleWalletModal = useWalletModalToggle() + // error handling modals + const { handleCloseError, handleSetError, ErrorModal } = useErrorModal() + + // get user claim data + const userClaimData = useUserEnhancedClaimData(activeClaimAccount) + + // get total unclaimed amount + const unclaimedAmount = useUserUnclaimedAmount(activeClaimAccount) + + const hasClaims = useMemo(() => userClaimData.length > 0, [userClaimData]) + const isAirdropOnly = useMemo(() => !hasPaidClaim(userClaimData), [userClaimData]) + const isPaidClaimsOnly = useMemo(() => hasPaidClaim(userClaimData) && !hasFreeClaim(userClaimData), [userClaimData]) + + // claim callback + const { claimCallback, estimateGasCallback } = useClaimCallback(activeClaimAccount) + + // handle change account + const handleChangeAccount = () => { + setActiveClaimAccount('') + setSelected([]) + setClaimStatus(ClaimStatus.DEFAULT) + setIsSearchUsed(true) + } + + // check claim + const handleCheckClaim = () => { + setActiveClaimAccount(resolvedAddress || '') + setActiveClaimAccountENS(resolvedENS || '') + setInputAddress('') + } + + // aggregate the input for claim callback + const claimInputData = useMemo(() => { + const freeClaims = getFreeClaims(userClaimData) + const paidClaims = prepareInvestClaims(investFlowData, userClaimData) + + const inputData = freeClaims.map(({ index }) => ({ index })) + return inputData.concat(paidClaims) + }, [investFlowData, userClaimData]) + + // track gas price estimation for given input data + useEffect(() => { + estimateGasCallback(claimInputData).then((gas) => setEstimatedGas(gas?.toString() || '')) + }, [claimInputData, estimateGasCallback, setEstimatedGas]) + + // handle submit claim + const handleSubmitClaim = useCallback(() => { + // Reset error handling + handleCloseError() + + // just to be sure + if (!activeClaimAccount) return + + const sendTransaction = (inputData: ClaimInput[]) => { + setClaimStatus(ClaimStatus.ATTEMPTING) + claimCallback(inputData) + .then((vCowAmount) => { + setClaimStatus(ClaimStatus.SUBMITTED) + setClaimedAmount(vCowAmount) + }) + .catch((error) => { + setClaimStatus(ClaimStatus.DEFAULT) + console.error('[Claim::index::handleSubmitClaim]::error', error) + handleSetError(error?.message) + }) + } + + // check if there are any selected (paid) claims + if (!selected.length) { + console.log('Starting claiming with', claimInputData) + sendTransaction(claimInputData) + } else if (investFlowStep == 2) { + console.log('Starting claiming with', claimInputData) + sendTransaction(claimInputData) + } else { + setIsInvestFlowActive(true) + } + }, [ + handleCloseError, + activeClaimAccount, + selected.length, + investFlowStep, + setClaimStatus, + claimCallback, + setClaimedAmount, + handleSetError, + claimInputData, + setIsInvestFlowActive, + ]) + + // on account/activeAccount/non-connected account (if claiming for someone else) change + useEffect(() => { + if (!isSearchUsed && account) { + setActiveClaimAccount(account) + } + + // properly reset the user to the claims table and initial investment flow + resetClaimUi() + }, [account, activeClaimAccount, resolvedAddress, isSearchUsed, setActiveClaimAccount, resetClaimUi]) + + // Transaction confirmation modal + const { TransactionConfirmationModal, openModal, closeModal } = useTransactionConfirmationModal( + OperationType.APPROVE_TOKEN + ) + + return ( + <PageWrapper> + {/* Approve confirmation modal */} + <TransactionConfirmationModal /> + {/* Error modal */} + <ErrorModal /> + {/* If claim is confirmed > trigger confetti effect */} + <Confetti start={claimStatus === ClaimStatus.CONFIRMED} /> + + {/* Top nav buttons */} + <ClaimNav account={account} handleChangeAccount={handleChangeAccount} /> + {/* Show general title OR total to claim (user has airdrop or airdrop+investment) --------------------------- */} + <EligibleBanner hasClaims={hasClaims} /> + {/* Show total to claim (user has airdrop or airdrop+investment) */} + <ClaimSummary hasClaims={hasClaims} unclaimedAmount={unclaimedAmount} /> + {/* Get address/ENS (user not connected yet or opted for checking 'another' account) */} + <ClaimAddress account={account} toggleWalletModal={toggleWalletModal} /> + {/* Is Airdrop only (simple) - does user have claims? Show messages dependent on claim state */} + <CanUserClaimMessage + hasClaims={hasClaims} + isAirdropOnly={isAirdropOnly} + handleChangeAccount={handleChangeAccount} + /> + + {/* Try claiming or inform successful claim */} + <ClaimingStatus /> + {/* IS Airdrop + investing (advanced) */} + <ClaimsTable isAirdropOnly={isAirdropOnly} hasClaims={hasClaims} /> + {/* Investing vCOW flow (advanced) */} + <InvestmentFlow isAirdropOnly={isAirdropOnly} hasClaims={hasClaims} modalCbs={{ openModal, closeModal }} /> + + <FooterNavButtons + handleCheckClaim={handleCheckClaim} + handleSubmitClaim={handleSubmitClaim} + toggleWalletModal={toggleWalletModal} + isAirdropOnly={isAirdropOnly} + isPaidClaimsOnly={isPaidClaimsOnly} + hasClaims={hasClaims} + resolvedAddress={resolvedAddress} + /> + </PageWrapper> + ) +} diff --git a/src/custom/pages/Claim/styled.ts b/src/custom/pages/Claim/styled.ts new file mode 100644 index 000000000..06ad05009 --- /dev/null +++ b/src/custom/pages/Claim/styled.ts @@ -0,0 +1,1197 @@ +import styled from 'styled-components/macro' +import { CheckCircle, Frown } from 'react-feather' +import BadgeOriginal from 'components/Badge' + +import { Icon } from 'components/CowProtocolLogo' +import { ButtonPrimary, ButtonSecondary } from 'components/Button' +import { transparentize, darken } from 'polished' +import LogoETH from 'assets/cow-swap/network-mainnet-logo.svg' +import LogoGNO from 'assets/cow-swap/gno.png' +import LogoUSDC from 'assets/cow-swap/usdc.png' +import LogoXDAI from 'assets/cow-swap/xdai.png' + +export const PageWrapper = styled.div` + --border-radius: 56px; + --border-radius-small: 16px; + display: flex; + flex-flow: column wrap; + max-width: 760px; + width: 100%; + color: ${({ theme }) => theme.text1}; + border-radius: var(--border-radius); + padding: 30px; + border: ${({ theme }) => theme.appBody.border}; + box-shadow: ${({ theme }) => theme.appBody.boxShadow}; + background: ${({ theme }) => theme.bg1}; + + ${({ theme }) => theme.mediaWidth.upToSmall` + padding: 16px; + border-radius: var(--border-radius-small); + `}; + + input[type='checkbox'], + input[type='radio'] { + --active: ${({ theme }) => theme.primary1}; + --active-inner: ${({ theme }) => theme.black}; + --focus: 2px rgba(39, 94, 254, .3); + --border: ${({ theme }) => theme.blueShade3}; + --border-hover: ${({ theme }) => theme.primary1}; + --background: ${({ theme }) => theme.white}; + appearance: none; + height: 21px; + width: 21px; + outline: none; + display: inline-block; + vertical-align: top; + position: relative; + margin: 0; + cursor: pointer; + border: 1px solid var(--bc, var(--border)); + background: var(--b, var(--background)); + + &:after { + content: ''; + display: block; + left: 0; + top: 0; + position: absolute; + } + + &:checked { + --b: var(--active); + --bc: var(--active); + } + + &:disabled { + cursor: not-allowed; + opacity: .5; + + &:checked { + } + + & + label { + cursor: not-allowed; + } + } + + &:hover { + &:not(:checked) { + &:not(:disabled) { + --bc: var(--border-hover); + } + } + } + + &:focus { + box-shadow: 0 0 0 var(--focus); + } + + &:after { + opacity: var(--o, 0); + } + + &:checked { + --o: 1; + } + + & + label { + font-size: 14px; + line-height: 21px; + display: inline-block; + vertical-align: top; + cursor: pointer; + margin-left: 4px; + } + } + + input[type='checkbox'] { + border-radius: 7px; + + &:after { + width: 5px; + height: 9px; + border: 2px solid var(--active-inner); + border-top: 0; + border-left: 0; + left: 7px; + top: 4px; + transform: rotate(var(--r, 20deg)); + } + + &:checked { + --r: 43deg; + } + } + + input[type='radio'] { + border-radius: 50%; + + &:after { + width: 19px; + height: 19px; + border-radius: 50%; + background: var(--active-inner); + opacity: 0; + transform: scale(var(--s, .7)); + } + } +} + +a { + color: ${({ theme }) => theme.primary4}; +} + +p { + font-size: 16px; + display: block; + line-height: 1.6; + font-weight: 300; + margin: 0 0 24px; + text-align: center; +} + +p > i { + color: ${({ theme }) => theme.primary1}; +} + +p > a { + display: inline; +} + +${ButtonPrimary} { + border-radius: var(--border-radius); + width: 100%; + font-size: 21px; + padding: 24px 16px; + + ${({ theme }) => theme.mediaWidth.upToSmall` + margin: 0 auto 24px; + `}; + + &[disabled] { + cursor: not-allowed; + pointer-events: all; + } +} + +${ButtonSecondary} { + background: 0; + color: ${({ theme }) => theme.primary4}; + border: none; + + &:hover { + border: 0; + box-shadow: none; + transform: none; + background: 0; + color: ${({ theme }) => theme.primary4}; + text-decoration: underline; + } +} +` + +export const TokenLogo = styled.div<{ symbol: string; size: number }>` + display: flex; + width: ${({ size }) => `${size}px`}; + height: ${({ size }) => `${size}px`}; + border-radius: ${({ size }) => `${size}px`}; + background: ${({ symbol, theme }) => `url(${_getLogo(symbol) || theme.blueShade3}) no-repeat center/contain`}; +` + +function _getLogo(symbol: string) { + switch (symbol.toUpperCase()) { + case 'GNO': + return LogoGNO + case 'USDC': + return LogoUSDC + case 'ETH': + return LogoETH + case 'XDAI': + return LogoXDAI + default: + return undefined + } +} + +export const ClaimSummary = styled.div` + display: flex; + width: 100%; + align-items: center; + justify-content: flex-start; + padding: 8px; + background: ${({ theme }) => (theme.currencyInput?.background ? theme.currencyInput?.background : theme.bg1)}; + border: ${({ theme }) => + theme.currencyInput?.border ? theme.currencyInput?.border : `border: 1px solid ${theme.bg2}`}; + border-radius: var(--border-radius); + margin: 0 auto 24px; + position: relative; + overflow: hidden; + + h1, + div { + z-index: 1; + } + + p { + margin: 0; + display: block; + } + + > div { + margin: 0 0 0 18px; + } +` + +export const ClaimSummaryTitle = styled.h1` + font-size: 1.6rem; + margin-left: 15px; +` + +export const IntroDescription = styled.div<{ center?: boolean }>` + display: block; + width: 100%; + margin: 0 0 24px; + line-height: 1.6; + + text-align: ${({ center }) => (center ? 'center' : 'initial')}; + + > p { + margin: 8px auto 24px; + } + + > p > i { + color: ${({ theme }) => theme.text1}; + font-weight: 600; + font-style: normal; + } + + > button { + width: auto; + display: inline; + } +` + +export const ClaimTable = styled.div` + display: flex; + flex-flow: column wrap; + width: 100%; + margin: 0 0 24px; + + ${TokenLogo}, + ${Icon} { + border: 2px solid ${({ theme }) => theme.blueShade3}; + } + + ${TokenLogo} { + margin: 0 -16px 0 0; + } + + table { + display: grid; + border-collapse: collapse; + min-width: 100%; + font-size: 16px; + grid-template-columns: min-content auto auto 240px; + } + + thead, + tbody, + tr { + display: contents; + } + + tr > td { + background: ${({ theme }) => theme.blueShade3}; + } + + th, + td { + padding: 15px; + } + + th { + &:first-child { + display: flex; + align-items: center; + } + + background: transparent; + text-align: left; + font-weight: normal; + font-size: 15px; + color: ${({ theme }) => theme.text1}; + position: relative; + } + + th:last-child { + border: 0; + } + + td { + display: flex; + align-items: center; + color: ${({ theme }) => theme.text1}; + word-break: break-word; + background: ${({ theme }) => theme.blueShade3}; + } + + td > b { + font-weight: 300; + } + + tr > td { + margin: 0 0 12px; + } + + tr > td:nth-of-type(2) { + > span { + margin: 0 12px 0 0; + display: flex; + flex-flow: column wrap; + } + + > span > i { + font-style: normal; + font-size: 15px; + } + } + + /* 3rd row - amount */ + + tr > td:nth-of-type(3) { + font-size: 18px; + font-weight: 500; + } + + tr > td:nth-of-type(4) { + font-size: 13px; + display: flex; + flex-flow: column wrap; + align-items: flex-start; + gap: 4px; + + > span { + color: ${({ theme }) => transparentize(0.1, theme.text1)}; + font-weight: 300; + } + + > span > b { + font-weight: 500; + color: ${({ theme }) => theme.text1}; + } + } + + tr > td:first-of-type { + border-top-left-radius: 12px; + border-bottom-left-radius: 12px; + } + + tr > td:last-of-type { + border-top-right-radius: 12px; + border-bottom-right-radius: 12px; + } +` + +export const ClaimRow = styled.tr<{ isPending?: boolean }>` + > td { + background-color: ${({ theme, isPending }) => (isPending ? '#221954' : theme.bg5)}; + cursor: ${({ isPending }) => (isPending ? 'pointer' : 'initial')}; + + &:first-child { + border-radius: 8px 0 0 8px; + } + + &:last-child { + border-radius: 0 8px 8px 0; + } + } +` + +export const ClaimAccount = styled.div` + display: flex; + flex-flow: row nowrap; + width: 100%; + justify-content: space-between; + align-items: center; + margin: 0 auto; + + > b { + font-size: 13px; + margin: 0 0 6px; + font-weight: normal; + } + + > div { + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + } + + > div > img { + height: 46px; + width: 46px; + border-radius: 46px; + object-fit: contain; + background-color: grey; + } + + > div > p { + margin: 0 0 0 10px; + font-size: 18px; + font-weight: normal; + } +` + +export const ClaimTotal = styled.div` + display: flex; + flex-flow: column wrap; + width: 100%; + justify-content: flex-start; + align-items: flex-start; + + > b { + font-size: 14px; + font-weight: normal; + margin: 0 0 2px; + opacity: 0.7; + } + + > p { + margin: 0; + font-size: 30px; + font-weight: bold; + + ${({ theme }) => theme.mediaWidth.upToSmall` + font-size: 16px; + `}; + } +` + +export const ConfirmOrLoadingWrapper = styled.div<{ activeBG: boolean }>` + width: 100%; + padding: 24px; + color: ${({ theme }) => theme.text1}; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + font-size: 26px; + font-weight: 300; + + h3 { + font-size: 26px; + font-weight: 600; + line-height: 1.2; + text-align: center; + margin: 0 0 12px; + color: ${({ theme }) => theme.text1}; + } +` + +export const AttemptFooter = styled.div` + display: flex; + width: 100%; + justify-content: center; + align-items: center; + margin: 24px 0 0; + + > p { + font-size: 14px; + opacity: 0.7; + margin: 0; + } +` + +export const ConfirmedIcon = styled.div` + padding: 60px 0; +` + +export const CheckIcon = styled(CheckCircle)` + height: 16px; + width: 16px; + margin-right: 6px; + stroke: ${({ theme }) => theme.primary1}; +` + +export const NegativeIcon = styled(Frown)` + height: 16px; + width: 16px; + margin-right: 6px; + stroke: ${({ theme }) => theme.primary1}; +` + +export const EligibleBanner = styled.div` + width: 100%; + border-radius: var(--border-radius); + padding: 12px; + text-align: center; + display: flex; + justify-content: center; + align-items: center; + background: ${({ theme }) => transparentize(0.9, theme.attention)}; + color: ${({ theme }) => theme.attention}; + margin: 0 auto 16px; + font-weight: 600; + + ${({ theme }) => theme.mediaWidth.upToSmall` + text-align: left; + padding: 18px; + `} + > img { + margin: 0 6px 0 0; + width: 21px; + height: 21px; + + ${({ theme }) => theme.mediaWidth.upToSmall` + margin: 0 12px 0 6px; + `} + } +` + +export const InputField = styled.div` + padding: 18px 18px 18px 36px; + border-radius: var(--border-radius); + border: ${({ theme }) => theme.currencyInput?.border}; + color: ${({ theme }) => theme.text1}; + display: flex; + flex-flow: row wrap; + background: ${({ theme }) => theme.currencyInput?.background}; + width: 100%; + margin: 0 0 24px; + + > input { + background: transparent; + border: 0; + font-size: 24px; + outline: 0; + color: ${({ theme }) => theme.text1}; + width: 100%; + } + + > input::placeholder { + color: inherit; + opacity: 0.7; + } + + > b { + display: flex; + margin: 0 0 12px; + align-items: center; + font-size: 18px; + font-weight: 500; + background-color: ${({ theme }) => theme.bg5}; + color: ${({ theme }) => theme.white}; + border-radius: 16px; + box-shadow: 0 6px 10px rgba(0, 0, 0, 0.075); + outline: none; + cursor: pointer; + user-select: none; + border: none; + height: 2.4rem; + width: auto; + flex: 0 1 auto; + padding: 0 8px; + justify-content: space-between; + + &:focus, + &:hover { + background-color: ${({ theme }) => darken(0.05, theme.bg5)}; + } + } + + > div { + display: flex; + width: 100%; + } + + > div > p { + display: flex; + align-items: center; + margin: 0 0 0 6px; + padding: 0; + font-size: 22px; + font-weight: 600; + color: ${({ theme }) => theme.text1}; + } + + > span { + display: flex; + flex: 1 1 100%; + } + + > span > ${ButtonSecondary} { + display: inline-block; + font-size: 14px; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } +` + +export const InputError = styled.div` + color: red; +` + +export const InputErrorText = styled.div` + margin: 0 0 24px; +` + +export const InputFieldTitle = styled.div` + display: flex; + align-items: center; + margin: 0 0 12px; + font-weight: normal; + color: inherit; + + > b { + margin-right: 10px; + } +` + +export const CheckAddress = styled.div` + display: flex; + width: 100%; + flex-flow: column wrap; + + ${Icon} { + margin: 0 auto; + + ${({ theme }) => theme.mediaWidth.upToSmall` + margin: 0 0 0 6px; + `} + > h1 { + font-size: 32px; + font-weight: 300; + text-align: center; + } + + > h1 > b { + font-weight: bold; + } + + > p { + text-align: center; + font-size: 18px; + line-height: 1.2; + margin: 0 0 24px; + } +` + +export const ClaimBreakdown = styled.div` + display: flex; + width: 100%; + flex-flow: column wrap; + + > p { + font-size: 16px; + line-height: 1.6; + font-weight: 300; + margin: 0 0 24px; + text-align: center; + } +` + +export const FooterNavButtons = styled.div` + margin-top: 20px; + display: flex; + width: 100%; + flex-flow: column wrap; + + ${ButtonSecondary} { + margin: 24px auto 0; + color: var(--colorgrey); + transition: color 0.2s ease-in-out; + + &:hover { + color: ${({ theme }) => theme.primary1}; + text-decoration: underline; + } + + > svg { + margin: 0 6px 0 0; + } + } +` + +export const ReadMoreText = styled.div` + margin: 18px 0; + text-align: center; + font-size: 15px; +` + +export const TopNav = styled.div` + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + background: transparent; + padding: 0; + margin: 0 auto 24px; + + ${ButtonSecondary} { + margin: 0; + color: ${({ theme }) => theme.text1}; + font-size: 15px; + width: auto; + } +` + +export const InvestFlow = styled.div` + display: flex; + flex-flow: column wrap; + + h1 { + font-size: 28px; + font-weight: 500; + text-align: center; + } +` + +export const InvestContent = styled.div` + display: flex; + flex-flow: column wrap; + + ${ClaimTable} { + table { + display: grid; + border-collapse: collapse; + min-width: 100%; + font-size: 14px; + grid-template-columns: repeat(3, auto); + + tr > td { + flex-flow: column wrap; + align-content: center; + gap: 18px; + font-weight: 300; + font-size: 14px; + } + + tr > td > span { + font-size: inherit; + display: flex; + flex-flow: column wrap; + + > i { + font-style: normal; + } + + &:last-child { + width: 100%; + } + } + + tr > td:nth-of-type(1) { + flex-flow: row wrap; + align-content: center; + gap: 6px; + + > span > b, + > b { + font-size: 16px; + font-weight: bold; + } + + > span > i { + font-size: 15px; + } + } + + tr > td:nth-of-type(2) { + flex-flow: column wrap; + align-items: flex-start; + align-content: flex-start; + justify-content: center; + + > span { + margin: 0; + } + + > i { + font-style: normal; + font-size: 18px; + font-weight: 500; + } + } + + tr > td:nth-of-type(3) { + font-weight: 300; + font-size: 14px; + justify-content: flex-start; + + > span { + width: 100%; + } + } + } + } +` + +export const InvestSummaryTable = styled.table` + ${TokenLogo} { + margin: 0 -28px 0 0; + } + + ${TokenLogo}, + ${Icon} { + border: 2px solid ${({ theme }) => theme.blueShade3}; + } +` + +export const InvestTokenGroup = styled.div` + display: flex; + flex-flow: row; + width: 100%; + padding: 24px; + margin: 0 0 24px; + border-radius: 12px; + background: ${({ theme }) => theme.blueShade3}; + + ${TokenLogo}, + ${Icon} { + border: 4px solid ${({ theme }) => theme.blueShade3}; + } + + > div { + display: flex; + flex-flow: column wrap; + flex: 0 1 auto; + padding: 0 32px 0 0; + } + + > div > span { + display: flex; + flex-flow: row nowrap; + justify-content: flex-start; + align-items: flex-start; + margin: 0 25px 0 0; + } + + > div > h3 { + font-size: 21px; + font-weight: 600; + margin: 0 0 18px; + } + + ${TokenLogo} { + margin: 0 -36px 0 0; + } + + > span { + display: flex; + flex-flow: row wrap; + justify-content: flex-start; + gap: 18px; + } +` + +export const UnderlineButton = styled.button` + display: flex; + align-items: center; + gap: 0 6px; + + background: none; + border: 0; + cursor: pointer; + color: ${({ theme }) => theme.primary4}; + text-decoration: underline; + text-align: left; + padding: 0; + + &:hover { + color: ${({ theme }) => theme.text1}; + } + + &:disabled { + text-decoration: none; + color: ${({ theme }) => theme.disabled}; + cursor: auto; + } +` + +export const InvestInput = styled.span` + display: flex; + flex-flow: column wrap; + font-size: 15px; + width: 100%; + + > div { + display: flex; + flex-flow: column wrap; + gap: 8px; + width: 100%; + } + + > div > label { + display: flex; + flex-flow: row wrap; + padding: 12px; + position: relative; + background: ${({ theme }) => (theme.currencyInput?.background ? theme.currencyInput?.background : theme.bg1)}; + border: ${({ theme }) => + theme.currencyInput?.border ? theme.currencyInput?.border : `border: 1px solid ${theme.bg2}`}; + border-radius: 12px; + + > span { + margin-left: 5px; + } + + &:hover { + border: ${({ theme }) => + theme.currencyInput?.border ? theme.currencyInput?.border : `border: 1px solid ${theme.bg2}`}; + } + } + + > div > label > b { + text-transform: uppercase; + display: flex; + align-items: center; + position: absolute; + right: 12px; + top: 28px; + bottom: 0; + margin: auto; + font-weight: normal; + color: ${({ theme }) => theme.text1}; + background: ${({ theme }) => theme.bg5}; + border-radius: 12px; + padding: 0 12px; + height: 32px; + } + + > div > label > input { + color: ${({ theme }) => theme.text1}; + border: none; + padding: 12px 70px 0 0; + font-size: 26px; + outline: 0; + background: transparent; + width: 100%; + line-height: 1; + text-align: left; + + &::placeholder { + opacity: 0.5; + line-height: 1; + } + } + + > div > small { + color: red; + margin: 12px 0; + font-size: 15px; + + &.warn { + color: orange; + } + } + + > div > i { + font-style: normal; + } + + > div > label > span { + display: flex; + width: 100%; + font-size: 14px; + } + + > div > label > span > b { + margin: 0 3px 0 0; + font-weight: normal; + } + + > div > Label > span > i { + font-style: normal; + } + + > div > label > span > ${UnderlineButton} { + margin-left: 4px; + } +` + +export const InvestAvailableBar = styled.div<{ percentage?: number }>` + width: 100%; + display: flex; + position: relative; + height: 17px; + align-items: center; + justify-content: flex-start; + overflow: hidden; + border-radius: 24px; + background: ${({ theme }) => theme.bg1}; + margin: 6px 0; + padding: 0; + + &::before { + content: ''; + display: block; + background: ${({ theme }) => + `linear-gradient(to right, ${transparentize(0.2, theme.primary5)}, ${theme.primary4})`}; + height: 100%; + border-radius: 24px 0 0 24px; + position: absolute; + left: 0; + top: 0; + bottom: 0; + transition: width 0.3s ease-in-out; + width: ${({ percentage }) => (percentage ? `${percentage}%` : '0%')}; + } + + &::after { + content: ${({ percentage }) => (percentage ? `'${percentage}%'` : '0%')}; + display: block; + font-size: 12px; + color: ${({ theme }) => theme.text1}; + z-index: 1; + height: 100%; + width: ${({ percentage }) => (percentage ? `${percentage}%` : '0%')}; + transition: width 0.3s ease-in-out; + margin: 0; + padding: 1px 4px 0 4px; + min-width: max-content; + text-align: right; + } +` + +export const InvestSummary = styled.div` + width: 100%; + display: grid; + grid-template-columns: repeat(2, 1fr); + font-size: 15px; + gap: 16px 36px; + + > span { + display: flex; + flex-flow: column wrap; + margin: 0 0 18px; + color: ${({ theme }) => transparentize(0.1, theme.text1)}; + gap: 4px; + } + + > span > ${ButtonPrimary} { + margin: 3px 0 12px -9px; + padding: 6px; + font-size: 16px; + } + + > span > i { + font-style: normal; + } + + > span > i > div > img { + margin: 0 0 0 4px; + height: 21px; + width: 21px; + } + + > span > b { + font-weight: 600; + color: ${({ theme }) => theme.text1}; + } +` + +export const InvestFlowValidation = styled.div` + width: 100%; + border-radius: var(--border-radius); + padding: 12px; + text-align: center; + display: flex; + justify-content: center; + align-items: center; + background: rgb(255 0 0 / 25%); + color: red; + margin: 0 auto 16px; +` + +export const ClaimAccountButtons = styled.div` + display: flex; + flex-direction: column; +` + +export const AccountClaimSummary = styled.div` + display: flex; + flex-flow: row wrap; + gap: 12px; + margin: 24px 0; + + > span { + display: flex; + flex-flow: column wrap; + white-space: pre-wrap; + gap: 3px; + } + + > span > i { + font-style: normal; + word-break: break-all; + } +` + +export const Badge = styled(BadgeOriginal)` + font-size: 11px; +` + +export const CowSpinner = styled.div` + --circle-size: 120px; + --border-radius: 100%; + --border-size: 2px; + border-radius: var(--circle-size); + height: var(--circle-size); + width: var(--circle-size); + margin: 0 auto 8px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + + &::after { + content: ''; + position: absolute; + top: var(--border-size); + right: var(--border-size); + bottom: var(--border-size); + left: var(--border-size); + z-index: 0; + border-radius: calc(var(--border-radius) - var(--border-size)); + box-shadow: inset 0 1px 1px 0 hsl(0deg 0% 100% / 10%), 0 10px 40px -20px #000000; + } + + &::before { + content: ''; + ${({ theme }) => theme.iconGradientBorder}; + display: block; + width: var(--circle-size); + padding: 0; + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + margin: auto; + border-radius: 100%; + z-index: 0; + animation: spin 1.5s linear infinite; + } + + > span { + height: 94%; + width: 94%; + padding: 0; + stroke: ${({ theme }) => theme.text1}; + border-radius: var(--circle-size); + z-index: 1; + } + + @keyframes spin { + from { + transform: rotate(0); + } + to { + transform: rotate(360deg); + } + } +` diff --git a/src/custom/pages/Claim/types.ts b/src/custom/pages/Claim/types.ts new file mode 100644 index 000000000..8b2e7e579 --- /dev/null +++ b/src/custom/pages/Claim/types.ts @@ -0,0 +1,27 @@ +import { UserClaimData } from 'state/claim/hooks' +import { Currency, CurrencyAmount, Price, Token } from '@uniswap/sdk-core' +import { SyntheticEvent } from 'react' +import { GpEther } from 'constants/tokens' + +export type ClaimCommonTypes = { + account: string | null | undefined + hasClaims: boolean + tokenCurrencyAmount: CurrencyAmount<Token> + handleChangeAccount: (e: SyntheticEvent<HTMLButtonElement>) => void +} + +// Enhanced UserClaimData with useful additional properties +export type EnhancedUserClaimData = UserClaimData & { + claimAmount: CurrencyAmount<Token> + isFree: boolean + currencyAmount?: CurrencyAmount<Token | GpEther> | undefined + price?: Price<Currency, Currency> | undefined + cost?: CurrencyAmount<Currency> | undefined +} + +export type InvestmentAmounts = { + vCowAmount?: CurrencyAmount<Currency> + investmentCost?: CurrencyAmount<Currency> +} + +export type ClaimWithInvestmentData = EnhancedUserClaimData & InvestmentAmounts diff --git a/src/custom/pages/Profile/VCOWDropdown.tsx b/src/custom/pages/Profile/VCOWDropdown.tsx new file mode 100644 index 000000000..b1030ac70 --- /dev/null +++ b/src/custom/pages/Profile/VCOWDropdown.tsx @@ -0,0 +1,152 @@ +import { useCallback, /* useMemo, */ useRef, useState } from 'react' +import styled from 'styled-components/macro' +import { useOnClickOutside } from 'hooks/useOnClickOutside' +import { ChevronDown } from 'react-feather' +import { CurrencyAmount, Token } from '@uniswap/sdk-core' +import { Txt } from 'assets/styles/styled' +import CowProtocolLogo from 'components/CowProtocolLogo' +import { formatMax, formatSmart } from 'utils/format' +import { AMOUNT_PRECISION } from '@src/custom/constants' + +type VCOWDropdownProps = { + balance?: CurrencyAmount<Token> +} + +export default function VCOWDropdown({ balance }: VCOWDropdownProps) { + const [open, setOpen] = useState(false) + const toggle = useCallback(() => setOpen((open) => !open), []) + const node = useRef<HTMLDivElement>(null) + useOnClickOutside(node, open ? toggle : undefined) + + // Disabled dropdown for now + // const hasBalance = useMemo(() => balance?.greaterThan(0), [balance]) + const hasBalance = false + + return ( + <Wrapper ref={node}> + <DropdownWrapper onClick={toggle} hasBalance={hasBalance}> + <span style={{ marginRight: '2px' }}> + <VCOWBalance> + <CowProtocolLogo size={46} /> + <ProfileFlexCol> + <Txt fs={14}>Balance</Txt> + <Txt fs={18} title={`${formatMax(balance)} vCOW`}> + <strong> + {formatSmart(balance, AMOUNT_PRECISION, { thousandSeparator: true, isLocaleAware: true }) ?? '0'} vCOW + </strong> + </Txt> + </ProfileFlexCol> + </VCOWBalance> + </span> + {hasBalance && <ChevronDown size={16} style={{ marginTop: '2px' }} strokeWidth={2.5} />} + </DropdownWrapper> + + {open && hasBalance && ( + <MenuFlyout> + <ProfileFlexWrap> + <ProfileFlexCol> + <Txt fs={16}>Voting Power</Txt> + <Txt fs={16}>Vesting</Txt> + <Txt fs={16}> + <strong>Total</strong> + </Txt> + </ProfileFlexCol> + <ProfileFlexCol> + <Txt fs={16}>000</Txt> + <Txt fs={16}>000</Txt> + <Txt fs={16}> + <strong>000</strong> + </Txt> + </ProfileFlexCol> + </ProfileFlexWrap> + </MenuFlyout> + )} + </Wrapper> + ) +} + +const Wrapper = styled.div` + position: relative; + display: inline; + ${({ theme }) => theme.mediaWidth.upToMedium` + justify-self: end; + `}; + + ${({ theme }) => theme.mediaWidth.upToVerySmall` + margin: 0 0.5rem 0 0; + width: initial; + text-overflow: ellipsis; + flex-shrink: 1; + justify-self: stretch; + `}; +` + +const MenuFlyout = styled.span` + background-color: ${({ theme }) => theme.bg4}; + border: 1px solid ${({ theme }) => theme.bg0}; + box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.01), 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 16px 24px rgba(0, 0, 0, 0.04), + 0px 24px 32px rgba(0, 0, 0, 0.01); + border-radius: 12px; + padding: 1rem; + display: flex; + flex-direction: column; + font-size: 1rem; + position: absolute; + right: 0; + top: 4.5rem; + z-index: 200; + min-width: 100%; +` + +export const DropdownWrapper = styled.button<{ hasBalance?: boolean }>` + align-items: center; + background-color: ${({ theme }) => theme.bg4}; + border-radius: 12px; + border: 1px solid ${({ theme }) => theme.bg0}; + color: ${({ theme }) => theme.text1}; + display: inline-flex; + flex-direction: row; + font-weight: 700; + font-size: 12px; + height: 100%; + padding: 0.2rem 0.4rem; + + :hover, + :focus { + cursor: ${({ hasBalance }) => (hasBalance ? 'pointer' : 'inherit')}; + outline: none; + border: ${({ hasBalance }) => (hasBalance ? '1px solid ${({ theme }) => theme.bg3}' : '1px solid transparent')}; + } + ${({ theme }) => theme.mediaWidth.upToVerySmall` + min-width: 100%; + justify-content: space-between; + `}; +` + +export const VCOWBalance = styled.div` + display: flex; + flex-direction: row; + align-items: center; + flex-grow: 1; + height: 56px; + justify-content: center; + border-radius: 12px; + padding: 8px; + background-color: ${({ theme }) => theme.bg4}; +` + +export const ProfileFlexCol = styled.div` + display: flex; + align-items: flex-start; + flex-direction: column; + + span { + padding: 0 8px; + } +` +export const ProfileFlexWrap = styled.div` + display: flex; + align-items: center; + flex-direction: row; + justify-content: space-between; +` diff --git a/src/custom/pages/Profile/index.tsx b/src/custom/pages/Profile/index.tsx index 61cdae6e7..bc9947f80 100644 --- a/src/custom/pages/Profile/index.tsx +++ b/src/custom/pages/Profile/index.tsx @@ -6,13 +6,14 @@ import { Container, GridWrap, CardHead, - StyledTitle, StyledContainer, StyledTime, ItemTitle, ChildWrapper, Loader, ExtLink, + ProfileWrapper, + ProfileGridWrap, } from 'pages/Profile/styled' import { useActiveWeb3React } from 'hooks/web3' import Copy from 'components/Copy/CopyMod' @@ -28,6 +29,12 @@ import NotificationBanner from 'components/NotificationBanner' import { SupportedChainId as ChainId } from 'constants/chains' import AffiliateStatusCheck from 'components/AffiliateStatusCheck' import { useHasOrders } from 'api/gnosisProtocol/hooks' +import { Title } from 'components/Page' +import { useTokenBalance } from 'state/wallet/hooks' +import { V_COW } from 'constants/tokens' +import VCOWDropdown from './VCOWDropdown' + +import { IS_CLAIMING_ENABLED } from 'pages/Claim/const' export default function Profile() { const referralLink = useReferralLink() @@ -37,6 +44,8 @@ export default function Profile() { const isTradesTooltipVisible = account && chainId == 1 && !!profileData?.totalTrades const hasOrders = useHasOrders(account) + const vCowBalance = useTokenBalance(account || undefined, chainId ? V_COW[chainId] : undefined) + const renderNotificationMessages = ( <> {error && ( @@ -54,11 +63,19 @@ export default function Profile() { return ( <Container> + <ProfileWrapper> + <ProfileGridWrap horizontal> + <CardHead> + <Title>Profile + + {IS_CLAIMING_ENABLED && vCowBalance && } + + {chainId && chainId === ChainId.MAINNET && } - Profile overview + Affiliate Program {account && ( diff --git a/src/custom/pages/Profile/styled.tsx b/src/custom/pages/Profile/styled.tsx index 27f6ce5f5..b5ca6911b 100644 --- a/src/custom/pages/Profile/styled.tsx +++ b/src/custom/pages/Profile/styled.tsx @@ -1,5 +1,5 @@ import styled, { css } from 'styled-components/macro' -import Page, { GdocsListStyle, Title } from 'components/Page' +import Page, { GdocsListStyle } from 'components/Page' import * as CSS from 'csstype' import { transparentize } from 'polished' import { ExternalLink } from 'theme' @@ -76,7 +76,7 @@ export const GridWrap = styled.div theme.mediaWidth.upToSmall` - justify-content: center; - font-size: 24px; - `} -` - export const StyledTime = styled.p` margin: 0; ` @@ -165,12 +153,13 @@ export const FlexCol = styled.div` span:not([role='img']) { font-size: 14px; color: ${({ theme }) => theme.text6}; - min-height: 32px; text-align: center; display: flex; align-items: center; + padding: 8px 0 0 0; } ` + export const Loader = styled.div<{ isLoading: boolean }>` display: flex; flex: 1; @@ -206,3 +195,33 @@ export const Loader = styled.div<{ isLoading: boolean }>` } `} ` + +export const ProfileWrapper = styled(Wrapper)` + margin: 16px 0 16px 0; + padding: 16px 24px; + z-index: 2; + ${({ theme }) => theme.mediaWidth.upToVerySmall` + padding: 0 16px 16px; + `}; +` + +export const ProfileGridWrap = styled(GridWrap)` + grid-template-columns: 1fr auto; + justify-content: space-between; + align-items: center; + ${({ theme }) => theme.mediaWidth.upToSmall` + > :first-child, + > :nth-child(2) { + grid-column-start: auto; + grid-column-end: auto; + } + `}; + ${({ theme }) => theme.mediaWidth.upToVerySmall` + > :first-child, + > :nth-child(2) { + grid-column-start: 1; + grid-column-end: 1; + } + grid-row-gap: 0px; + `}; +` diff --git a/src/custom/pages/Swap/SwapMod.tsx b/src/custom/pages/Swap/SwapMod.tsx index d82775052..4011cff78 100644 --- a/src/custom/pages/Swap/SwapMod.tsx +++ b/src/custom/pages/Swap/SwapMod.tsx @@ -30,7 +30,7 @@ import { /* Row, */ AutoRow /*, RowFixed */ } from 'components/Row' // import BetterTradeLink from 'components/swap/BetterTradeLink' import confirmPriceImpactWithoutFee from 'components/swap/confirmPriceImpactWithoutFee' import ConfirmSwapModal from 'components/swap/ConfirmSwapModal' -import { /* ArrowWrapper, Dots, */ SwapCallbackError, Wrapper } from 'components/swap/styleds' +import { /* ArrowWrapper, Dots, */ /* SwapCallbackError, */ Wrapper } from 'components/swap/styleds' import SwapHeader from 'components/swap/SwapHeader' // import TradePrice from 'components/swap/TradePrice' // import { SwitchLocaleLink } from 'components/SwitchLocaleLink' @@ -63,7 +63,7 @@ import { useHighFeeWarning, useUnknownImpactWarning, } from 'state/swap/hooks' -import { useExpertModeManager } from 'state/user/hooks' +import { useExpertModeManager, useRecipientToggleManager } from 'state/user/hooks' import { /* HideSmall, */ LinkStyledButton, TYPE, ButtonSize } from 'theme' // import { computeFiatValuePriceImpact } from 'utils/computeFiatValuePriceImpact' // import { getTradeVersion } from 'utils/getTradeVersion' @@ -89,6 +89,7 @@ import { ApplicationModal } from 'state/application/reducer' import TransactionConfirmationModal, { OperationType } from 'components/TransactionConfirmationModal' import AffiliateStatusCheck from 'components/AffiliateStatusCheck' import usePriceImpact from 'hooks/usePriceImpact' +import { useErrorMessage } from 'hooks/useErrorMessageAndModal' // MOD - exported in ./styleds to avoid circ dep // export const StyledInfo = styled(Info)` @@ -169,6 +170,8 @@ export default function Swap({ // for expert mode const [isExpertMode] = useExpertModeManager() + const [recipientToggleVisible] = useRecipientToggleManager() + // get version from the url // const toggledVersion = useToggledVersion() @@ -326,12 +329,13 @@ export default function Swap({ const isLoadingRoute = toggledVersion === Version.v3 && V3TradeState.LOADING === v3TradeState */ // check whether the user has approved the router on the input token - const [approvalState, approveCallback] = useApproveCallbackFromTrade( - (message: string) => openTransactionConfirmationModal(message, OperationType.APPROVE_TOKEN), + const { approvalState, approve: approveCallback } = useApproveCallbackFromTrade({ + openTransactionConfirmationModal: (message: string) => + openTransactionConfirmationModal(message, OperationType.APPROVE_TOKEN), closeModals, trade, - allowedSlippage - ) + allowedSlippage, + }) const prevApprovalState = usePrevious(approvalState) const { state: signatureState, @@ -507,6 +511,8 @@ export default function Swap({ } } + const { ErrorMessage } = useErrorMessage() + return ( <> - + */} {/* GP ARROW SWITCHER */} - + - {recipient === null && !showWrap && isExpertMode ? ( + {recipient === null && !showWrap && (isExpertMode || recipientToggleVisible) ? ( onChangeRecipient('')}> + Add a send (optional) @@ -960,7 +969,7 @@ export default function Swap({ */} )} - {isExpertMode && swapErrorMessage ? : null} + {isExpertMode ? : null} diff --git a/src/custom/state/claim/actions.ts b/src/custom/state/claim/actions.ts new file mode 100644 index 000000000..00638bd46 --- /dev/null +++ b/src/custom/state/claim/actions.ts @@ -0,0 +1,65 @@ +import { createAction } from '@reduxjs/toolkit' + +export enum ClaimStatus { + DEFAULT = 'DEFAULT', + ATTEMPTING = 'ATTEMPTING', + SUBMITTED = 'SUBMITTED', + CONFIRMED = 'CONFIRMED', +} + +export type ClaimActions = { + // account + setInputAddress: (payload: string) => void + setActiveClaimAccount: (payload: string) => void + setActiveClaimAccountENS: (payload: string) => void + + // search + setIsSearchUsed: (payload: boolean) => void + + // claiming + setClaimStatus: (payload: ClaimStatus) => void + setClaimedAmount: (payload: string) => void + setEstimatedGas: (payload: string) => void + + // investing + setIsInvestFlowActive: (payload: boolean) => void + setInvestFlowStep: (payload: number) => void + initInvestFlowData: () => void + updateInvestAmount: (payload: { index: number; amount: string }) => void + updateInvestError: (payload: { index: number; error: string | undefined }) => void + + // claim row selection + setSelected: (payload: number[]) => void + setSelectedAll: (payload: boolean) => void +} + +// accounts +export const setInputAddress = createAction('claim/setInputAddress') +export const setActiveClaimAccount = createAction('claim/setActiveClaimAccount') +export const setActiveClaimAccountENS = createAction('claim/setActiveClaimAccountENS') + +// search +export const setIsSearchUsed = createAction('claim/setIsSearchUsed') + +// claiming +export const setClaimedAmount = createAction('claim/setClaimedAmount') +export const setClaimStatus = createAction('claim/setClaimStatus') +export const setEstimatedGas = createAction('claim/setEstimatedGas') + +// investing +export const setIsInvestFlowActive = createAction('claim/setIsInvestFlowActive') +export const setInvestFlowStep = createAction('claim/setInvestFlowStep') +export const initInvestFlowData = createAction('claim/initInvestFlowData') +export const updateInvestAmount = createAction<{ + index: number + amount: string +}>('claim/updateInvestAmount') +export const updateInvestError = createAction<{ + index: number + error: string | undefined +}>('claim/updateInvestError') +// claim row selection +export const setSelected = createAction('claim/setSelected') +export const setSelectedAll = createAction('claim/setSelectedAll') +// Claim UI reset sugar +export const resetClaimUi = createAction('claims/resetClaimUi') diff --git a/src/custom/state/claim/hooks/index.ts b/src/custom/state/claim/hooks/index.ts new file mode 100644 index 000000000..a88beb543 --- /dev/null +++ b/src/custom/state/claim/hooks/index.ts @@ -0,0 +1,924 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import JSBI from 'jsbi' +import ms from 'ms.macro' +import { CurrencyAmount, Price, Token } from '@uniswap/sdk-core' +import { TransactionResponse } from '@ethersproject/providers' +import { parseUnits } from '@ethersproject/units' +import { BigNumber } from '@ethersproject/bignumber' + +import { VCow as VCowType } from 'abis/types' + +import { useVCowContract } from 'hooks/useContract' +import { useActiveWeb3React } from 'hooks/web3' +import { useSingleContractMultipleData } from 'state/multicall/hooks' +import { useTransactionAdder } from 'state/enhancedTransactions/hooks' + +import { GpEther, V_COW } from 'constants/tokens' + +import { formatSmartLocaleAware } from 'utils/format' +import { calculateGasMargin } from 'utils/calculateGasMargin' +import { isAddress } from 'utils' + +import { + getClaimKey, + getClaimsRepoPath, + isFreeClaim, + claimTypeToTokenAmount, + transformRepoClaimsToUserClaims, +} from 'state/claim/hooks/utils' +import { SupportedChainId } from 'constants/chains' +import { registerOnWindow } from 'utils/misc' +import mockData, { MOCK_INDICES } from './mocks/claimData' +import { getIndexes } from './utils' +import { useAllClaimingTransactionIndices } from 'state/enhancedTransactions/hooks' + +export { useUserClaimData } from '@src/state/claim/hooks' + +import { AppDispatch } from 'state' +import { useSelector, useDispatch } from 'react-redux' +import { AppState } from 'state' + +import { + setInputAddress, + setActiveClaimAccount, + setActiveClaimAccountENS, + setIsSearchUsed, + setClaimStatus, + setClaimedAmount, + setIsInvestFlowActive, + setInvestFlowStep, + initInvestFlowData, + updateInvestAmount, + setSelected, + setSelectedAll, + ClaimStatus, + resetClaimUi, + updateInvestError, + setEstimatedGas, +} from '../actions' +import { EnhancedUserClaimData } from 'pages/Claim/types' +import { supportedChainId } from 'utils/supportedChainId' +import { AMOUNT_PRECISION } from 'constants/index' + +const CLAIMS_REPO_BRANCH = '2022-01-22-test-deployment-all-networks' +export const CLAIMS_REPO = `https://raw.githubusercontent.com/gnosis/cow-merkle-drop/${CLAIMS_REPO_BRANCH}/` + +// Base amount = 1 VCOW +const ONE_VCOW = CurrencyAmount.fromRawAmount( + V_COW[SupportedChainId.RINKEBY], + parseUnits('1', V_COW[SupportedChainId.RINKEBY].decimals).toString() +) + +// Constants regarding investment time windows +const INVESTMENT_TIME = ms`2 weeks` +const AIRDROP_TIME = ms`6 weeks` + +// For native token price calculation +const DENOMINATOR = JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(18)) + +export enum ClaimType { + Airdrop, // free, no vesting, can be available on both mainnet and gchain + GnoOption, // paid, with vesting, must use GNO, can be available on both mainnet and gchain + UserOption, // paid, with vesting, must use Native currency, can be available on both mainnet and gchain + Investor, // paid, with vesting, must use USDC, only on mainnet + Team, // free, with vesting, only on mainnet + Advisor, // free, with vesting, only on mainnet +} + +export type TypeToPriceMapper = Map + +// Hardcoded values +export const ClaimTypePriceMap: TypeToPriceMapper = new Map([ + [ClaimType.GnoOption, 16.66], + [ClaimType.Investor, 26.66], + [ClaimType.UserOption, 36.66], +]) + +type RepoClaimType = keyof typeof ClaimType + +export const FREE_CLAIM_TYPES: ClaimType[] = [ClaimType.Airdrop, ClaimType.Team, ClaimType.Advisor] +export const PAID_CLAIM_TYPES: ClaimType[] = [ClaimType.GnoOption, ClaimType.UserOption, ClaimType.Investor] + +export interface UserClaimData { + index: number + amount: string + proof: string[] + type: ClaimType +} + +export type RepoClaimData = Omit & { + type: RepoClaimType +} + +export interface ClaimInput { + /** + * The index of the claim + */ + index: number + /** + * The amount of the claim. Optional + * If not present, will claim the full amount + */ + amount?: string +} + +type Account = string | null | undefined + +export type UserClaims = UserClaimData[] +export type RepoClaims = RepoClaimData[] + +type ClassifiedUserClaims = { + available: UserClaims + expired: UserClaims + claimed: UserClaims +} + +/** + * Gets all user claims, classified + * + * @param account + */ +export function useClassifiedUserClaims(account: Account): ClassifiedUserClaims { + const userClaims = useUserClaims(account) + const contract = useVCowContract() + + const { isInvestmentWindowOpen, isAirdropWindowOpen } = useClaimTimeInfo() + + // build list of parameters, with the claim index + // we check for all claims because expired now might have been claimed before + const claimIndexes = useMemo(() => userClaims?.map(({ index }) => [index]) || [], [userClaims]) + + const results = useSingleContractMultipleData(contract, 'isClaimed', claimIndexes) + + return useMemo(() => { + const available: UserClaims = [] + const expired: UserClaims = [] + const claimed: UserClaims = [] + + if (!userClaims || userClaims.length === 0) { + return { available, expired, claimed } + } + + results.forEach((result, index) => { + const claim = userClaims[index] + + if ( + result.valid && // result is valid + !result.loading && // result is not loading + result.result?.[0] === true // result true means claimed + ) { + claimed.push(claim) + } else if (!isAirdropWindowOpen || (!isInvestmentWindowOpen && PAID_CLAIM_TYPES.includes(claim.type))) { + expired.push(claim) + } else { + available.push(claim) + } + }) + + return { available, expired, claimed } + }, [isAirdropWindowOpen, isInvestmentWindowOpen, results, userClaims]) +} + +/** + * Gets an array of available claims + * + * Syntactic sugar on top of `useClassifiedUserClaims` + * + * @param account + */ +export function useUserAvailableClaims(account: Account): UserClaims { + const { available } = useClassifiedUserClaims(account) + + return available +} + +/** + * Returns whether the user has any available claim + * Syntactic sugar on top of `useUserAvailableClaims` + * + * @param account + */ +export function useUserHasAvailableClaim(account: Account): boolean { + const availableClaims = useUserAvailableClaims(account) + + return availableClaims.length > 0 +} + +export function useUserUnclaimedAmount(account: string | null | undefined): CurrencyAmount | undefined { + const { chainId } = useActiveWeb3React() + const claims = useUserAvailableClaims(account) + const pendingIndices = useAllClaimingTransactionIndices() + + const vCow = chainId ? V_COW[chainId] : undefined + if (!vCow) return undefined + if (!claims || claims.length === 0) { + return CurrencyAmount.fromRawAmount(vCow, JSBI.BigInt(0)) + } + + const totalAmount = claims.reduce((acc, claim) => { + // don't add pending + if (pendingIndices.has(claim.index)) return acc + + return JSBI.add(acc, JSBI.BigInt(claim.amount)) + }, JSBI.BigInt('0')) + + return CurrencyAmount.fromRawAmount(vCow, JSBI.BigInt(totalAmount)) +} + +/** + * Gets user claims from claim repo + * Stores fetched claims in local state + * + * @param account + */ +export function useUserClaims(account: Account): UserClaims | null { + const { chainId } = useActiveWeb3React() + const [claimInfo, setClaimInfo] = useState<{ [account: string]: UserClaims | null }>({}) + + // We'll have claims on multiple networks + const claimKey = chainId && account && `${chainId}:${account}` + + useEffect(() => { + if (!claimKey) { + return + } + + fetchClaims(account, chainId) + .then((accountClaimInfo) => + setClaimInfo((claimInfo) => { + return { + ...claimInfo, + [claimKey]: accountClaimInfo, + } + }) + ) + .catch(() => { + setClaimInfo((claimInfo) => { + return { + ...claimInfo, + [claimKey]: null, + } + }) + }) + }, [account, chainId, claimKey]) + + return claimKey ? claimInfo[claimKey] : null +} + +// TODO: remove +const createMockTx = (data: number[]) => ({ + hash: '0x' + Math.round(Math.random() * 10).toString() + 'AxAFjAhG89G89AfnLK3CCxAfnLKQffQ782G89AfnLK3CCxxx123FF', + summary: `Claimed ${Math.random() * 3337} vCOW`, + claim: { recipient: '0x97EC4fcD5F78cA6f6E4E1EAC6c0Ec8421bA518B7', indices: data }, +}) + +/** + * Fetches from contract the deployment timestamp in ms + * + * Returns null if in there's no network or vCowContract doesn't exist + */ +function useDeploymentTimestamp(): number | null { + const { chainId } = useActiveWeb3React() + const vCowContract = useVCowContract() + const [timestamp, setTimestamp] = useState(null) + + useEffect(() => { + if (!chainId || !vCowContract) { + return + } + + vCowContract.deploymentTimestamp().then((ts) => { + console.log(`Deployment timestamp in seconds: ${ts.toString()}`) + setTimestamp(ts.mul('1000').toNumber()) + }) + }, [chainId, vCowContract]) + + return timestamp +} + +type ClaimTimeInfo = { + /** + * Time when contract was deployed, fetched from chain + */ + deployment: number | null + /** + * Time when investment window will close (2 weeks after contract deployment) + */ + investmentDeadline: number | null + /** + * Time when airdrop window will close (6 weeks after contract deployment) + */ + airdropDeadline: number | null + /** + * Whether investment window is still open, based on local time + */ + isInvestmentWindowOpen: boolean + /** + * Whether airdrop window is still open, based on local time + */ + isAirdropWindowOpen: boolean +} + +/** + * Overall Claim time related properties + */ +export function useClaimTimeInfo(): ClaimTimeInfo { + const deployment = useDeploymentTimestamp() + const investmentDeadline = deployment && deployment + INVESTMENT_TIME + const airdropDeadline = deployment && deployment + AIRDROP_TIME + + const isInvestmentWindowOpen = Boolean(investmentDeadline && investmentDeadline > Date.now()) + const isAirdropWindowOpen = Boolean(airdropDeadline && airdropDeadline > Date.now()) + + return { deployment, investmentDeadline, airdropDeadline, isInvestmentWindowOpen, isAirdropWindowOpen } +} + +export function useNativeTokenPrice(): string | null { + return _useVCowPriceForToken('nativeTokenPrice') +} + +export function useGnoPrice(): string | null { + return _useVCowPriceForToken('gnoPrice') +} + +export function useUsdcPrice(): string | null { + return _useVCowPriceForToken('usdcPrice') +} + +type VCowPriceFnNames = 'nativeTokenPrice' | 'gnoPrice' | 'usdcPrice' + +/** + * Generic hook for fetching contract value for the many prices + */ +function _useVCowPriceForToken(priceFnName: VCowPriceFnNames): string | null { + const { chainId } = useActiveWeb3React() + const vCowContract = useVCowContract() + + const [price, setPrice] = useState(null) + + useEffect(() => { + if (!chainId || !vCowContract) { + return + } + console.debug(`_useVCowPriceForToken::fetching price for `, priceFnName) + + vCowContract[priceFnName]().then((price: BigNumber) => setPrice(price.toString())) + }, [chainId, priceFnName, vCowContract]) + + return price +} + +export type VCowPrices = { + native: string | null + gno: string | null + usdc: string | null +} + +export function useVCowPrices(): VCowPrices { + const native = useNativeTokenPrice() + const gno = useGnoPrice() + const usdc = useUsdcPrice() + + return useMemo(() => ({ native, gno, usdc }), [gno, native, usdc]) +} + +/** + * Helper function that checks whether selected investment options are still available + * + * Throws when claims are no longer possible + */ +function _validateClaimable( + claims: UserClaims, + input: ClaimInput[], + isInvestmentStillAvailable: boolean, + isAirdropStillAvailable: boolean +): void { + if (!isAirdropStillAvailable) { + throw new Error(`Contract no longer accepts claims`) + } + + input.forEach(({ index }) => { + const claim = claims.find((claim) => claim.index === index) + + if (claim && !isInvestmentStillAvailable && PAID_CLAIM_TYPES.includes(claim.type)) { + throw new Error(`Contract no longer accepts investment type claims`) + } + }) +} + +/** + * Hook that returns the claimCallback + * + * Different from the original version, the returned callback takes as input a list of ClaimInputs, + * which is an object of the claim index and the amount being claimed. + * + * @param account + */ +export function useClaimCallback(account: string | null | undefined): { + claimCallback: (claimInputs: ClaimInput[]) => Promise + estimateGasCallback: (claimInputs: ClaimInput[]) => Promise +} { + // get claim data for given account + const { chainId, account: connectedAccount } = useActiveWeb3React() + const claims = useUserAvailableClaims(account) + const vCowContract = useVCowContract() + const nativeTokenPrice = useNativeTokenPrice() + + const { isInvestmentWindowOpen, isAirdropWindowOpen } = useClaimTimeInfo() + + // used for popup summary + const addTransaction = useTransactionAdder() + const vCowToken = chainId ? V_COW[chainId] : undefined + + // TODO: remove + registerOnWindow({ + addMockClaimTransactions: (data?: number[]) => { + let finalData: number[] | undefined = data + + if (!finalData) { + const mockDataIndices = connectedAccount ? getIndexes(mockData[connectedAccount] || []) : [] + finalData = mockDataIndices?.length > 0 ? mockDataIndices : MOCK_INDICES + } + + return addTransaction(createMockTx(finalData)) + }, + }) + + const getClaimArgs = useCallback( + async function (claimInput: ClaimInput[]): Promise { + if (claims.length === 0) { + throw new Error('User has no claims') + } + if (claimInput.length === 0) { + throw new Error('No claims selected') + } + if (!account) { + throw new Error('Claim account not set') + } + if (!connectedAccount) { + throw new Error('Not connected') + } + if (!nativeTokenPrice) { + throw new Error("There's no native token price") + } + + _validateClaimable(claims, claimInput, isInvestmentWindowOpen, isAirdropWindowOpen) + + return _getClaimManyArgs({ + claimInput, + claims, + account, + connectedAccount, + nativeTokenPrice, + }) + }, + [account, claims, connectedAccount, isAirdropWindowOpen, isInvestmentWindowOpen, nativeTokenPrice] + ) + + const estimateGasCallback = useCallback( + async function ( + claimInput: ClaimInput[], + claimArgs?: GetClaimManyArgsResult['args'] + ): Promise { + if (!vCowContract) { + return + } + + try { + let args = claimArgs + if (!claimArgs) { + const { args: _args } = await getClaimArgs(claimInput) + args = _args + } + + if (!args) { + console.debug('Failed to estimate gas for claiming: There were no valid claims selected') + return + } + + // Why unnecessarily awaiting here? + // Because I want to handle errors here. + // Not awaiting means the caller will have to deal with that, which I don't want in this case + return await vCowContract.estimateGas.claimMany(...args) + } catch (e) { + console.debug('Failed to estimate gas for claiming:', e.message) + return + } + }, + [getClaimArgs, vCowContract] + ) + + const claimCallback = useCallback( + /** + * Claim callback that sends tx to wallet to claim whatever user selected + * + * Returns a string with the formatted vCow amount being claimed + */ + async function (claimInput: ClaimInput[]): Promise { + if (claimInput.length === 0) { + throw new Error('No claims selected') + } + if (!account) { + throw new Error('Claim account not set') + } + if (!connectedAccount) { + throw new Error('Not connected') + } + if (!chainId) { + throw new Error('No chainId') + } + if (!vCowContract) { + throw new Error('vCOW contract not present') + } + if (!vCowToken) { + throw new Error('vCOW token not present') + } + + const { args, totalClaimedAmount } = await getClaimArgs(claimInput) + + if (!args) { + throw new Error('No valid claims selected') + } + + const gasLimit = await estimateGasCallback(claimInput, args) + + if (!gasLimit) { + throw new Error('Not able to estimate gasLimit') + } + + const vCowAmount = CurrencyAmount.fromRawAmount(vCowToken, totalClaimedAmount) + const formattedVCowAmount = formatSmartLocaleAware(vCowAmount, AMOUNT_PRECISION) || '0' + + const extendedArgs = _extendFinalArg(args, { + from: connectedAccount, // add the `from` as the connected account + gasLimit: calculateGasMargin(chainId, gasLimit), + }) + + return vCowContract.claimMany(...extendedArgs).then((response: TransactionResponse) => { + addTransaction({ + hash: response.hash, + summary: `Claim ${formattedVCowAmount} vCOW`, + claim: { recipient: account, indices: args[0] as number[] }, + }) + return vCowAmount.quotient.toString() + }) + }, + [account, addTransaction, chainId, connectedAccount, estimateGasCallback, getClaimArgs, vCowContract, vCowToken] + ) + + return { claimCallback, estimateGasCallback } +} + +type GetClaimManyArgsParams = { + claimInput: ClaimInput[] + claims: UserClaims + account: string + connectedAccount: string + nativeTokenPrice: string +} + +type ClaimManyFnArgs = Parameters + +type GetClaimManyArgsResult = { + args: ClaimManyFnArgs | undefined + totalClaimedAmount: JSBI +} + +/** + * Prepares the list of args to be passed to vCow.claimMany function + */ +function _getClaimManyArgs({ + claimInput, + claims, + account, + connectedAccount, + nativeTokenPrice, +}: GetClaimManyArgsParams): GetClaimManyArgsResult { + // Arrays are named according to contract parameters + // For more info, check https://github.com/gnosis/gp-v2-token/blob/main/src/contracts/mixins/MerkleDistributor.sol#L123 + const indices: ClaimManyFnArgs[0] = [] + const claimTypes: ClaimManyFnArgs[1] = [] + const claimants: ClaimManyFnArgs[2] = [] + const claimableAmounts: ClaimManyFnArgs[3] = [] + const claimedAmounts: ClaimManyFnArgs[4] = [] + const merkleProofs: ClaimManyFnArgs[5] = [] + const sendEth: ClaimManyFnArgs[6] = [] + + let totalClaimedAmount = JSBI.BigInt('0') + let totalValue = JSBI.BigInt('0') + + // Creating a map for faster access when checking what's being claimed + const claimsMap = claims.reduce>((acc, claim) => { + acc[claim.index] = claim + return acc + }, {}) + + claimInput.forEach((input) => { + const claim = claimsMap[input.index] + + // It can be that the index being passed is already claimed or belongs to another account + // Thus, it's possible that the returned `args` is "empty" + if (claim) { + indices.push(claim.index) + // always the same + claimants.push(account) + // always the max available + claimableAmounts.push(claim.amount) + + claimTypes.push(claim.type) + // depends on claim type and whether claimed account == connected account + const claimedAmount = _getClaimedAmount({ claim, input, account, connectedAccount }) + claimedAmounts.push(claimedAmount) + + merkleProofs.push(claim.proof) + // only used on UserOption + const value = _getClaimValue(claim, claimedAmount, nativeTokenPrice) + sendEth.push(value) // TODO: verify ETH balance < input.amount ? + + // sum of claimedAmounts for the toast notification + totalClaimedAmount = JSBI.add(totalClaimedAmount, JSBI.BigInt(claimedAmount)) + // sum of Native currency to be used on call options + totalValue = JSBI.add(totalValue, JSBI.BigInt(value)) + } + }) + + const value = totalValue.toString() === '0' ? undefined : totalValue.toString() + + const args: GetClaimManyArgsResult['args'] = + indices.length > 0 + ? [indices, claimTypes, claimants, claimableAmounts, claimedAmounts, merkleProofs, sendEth, { value }] + : undefined + + return { + args, + totalClaimedAmount, + } +} + +type GetClaimedAmountParams = Pick & { + claim: UserClaimData + input: ClaimInput +} + +/** + * Gets the allowed claimed amount based on claim type and whether claiming account === connectedAccount + * Rules are same as the contract to prevent reverts + */ +function _getClaimedAmount({ claim, input, account, connectedAccount }: GetClaimedAmountParams): string { + if ( + _isClaimForOther(account, connectedAccount, claim) || + isFreeClaim(claim.type) || + _hasNoInputOrInputIsGreaterThanClaimAmount(input, claim) || + // had to duplicate this check because I can't get TS to understand input.amount is not undefined in the else clause + !input.amount + ) { + // use full amount + return claim.amount + } else { + // use partial amount + return input.amount + } +} + +/** + * Claim 100% when claiming investment for someone else + */ +function _isClaimForOther(account: string, connectedAccount: string, claim: UserClaimData) { + return account !== connectedAccount && !isFreeClaim(claim.type) +} + +/** + * Claim 100% when input is not set + * Claim 100% when input > amount + */ +function _hasNoInputOrInputIsGreaterThanClaimAmount( + input: ClaimInput, + claim: UserClaimData +): input is Required { + return !input.amount || JSBI.greaterThan(JSBI.BigInt(input.amount), JSBI.BigInt(claim.amount)) +} + +/** + * Calculates native value based on claim vCowAmount and type + * + * Value will only be != '0' if claim type is UserOption + * Assumes the checks were done previously regarding which amounts are allowed + * + * The calculation is done based on the formula: + * vCowAmount * wethPrice / 10^18 + * See https://github.com/gnosis/gp-v2-token/blob/main/src/contracts/mixins/Claiming.sol#L314-L320 + */ +function _getClaimValue(claim: UserClaimData, vCowAmount: string, nativeTokenPrice: string): string { + if (claim.type !== ClaimType.UserOption) { + return '0' + } + + // Why InAtomsSquared? because we are multiplying vCowAmount (which is in atoms == * 10**18) + // by the price (which is also in atoms == * 10**18) + const claimValueInAtomsSquared = JSBI.multiply(JSBI.BigInt(vCowAmount), JSBI.BigInt(nativeTokenPrice)) + // Then it's divided by 10**18 to return the value in the native currency atoms + return JSBI.divide(claimValueInAtomsSquared, DENOMINATOR).toString() +} + +/** + * Extend the Payable optional param + */ +function _extendFinalArg(args: ClaimManyFnArgs, extendedArg: Record) { + const lastArg = args.pop() + args.push({ + ...lastArg, // add back whatever is already there + ...extendedArg, + }) + + return args +} + +type LastAddress = string +type ClaimAddressMapping = { [firstAddress: string]: LastAddress } +const FETCH_CLAIM_MAPPING_PROMISES: Record | null> = {} + +/** + * Customized fetchClaimMapping function + */ +function fetchClaimsMapping(chainId: number): Promise { + return ( + FETCH_CLAIM_MAPPING_PROMISES[chainId] ?? + (FETCH_CLAIM_MAPPING_PROMISES[chainId] = fetch(`${getClaimsRepoPath(chainId)}mapping.json`) + .then((res) => res.json()) + .catch((error) => { + console.error(`Failed to get claims mapping for chain ${chainId}`, error) + FETCH_CLAIM_MAPPING_PROMISES[chainId] = null + })) + ) +} + +const FETCH_CLAIM_FILE_PROMISES: { [startingAddress: string]: Promise<{ [address: string]: RepoClaims }> } = {} + +/** + * Customized fetchClaimFile function + */ +function fetchClaimsFile(address: string, chainId: number): Promise<{ [address: string]: RepoClaims }> { + const key = getClaimKey(address, chainId) + return ( + FETCH_CLAIM_FILE_PROMISES[key] ?? + (FETCH_CLAIM_FILE_PROMISES[key] = fetch(`${getClaimsRepoPath(chainId)}chunks/${address}.json`) // mod + .then((res) => res.json()) + .catch((error) => { + console.error(`Failed to get claim file mapping on chain ${chainId} for starting address ${address}`, error) + delete FETCH_CLAIM_FILE_PROMISES[key] + })) + ) +} + +const FETCH_CLAIM_PROMISES: { [key: string]: Promise } = {} + +/** + * Customized fetchClaim function + * Returns the claim for the given address, or null if not valid + */ +function fetchClaims(account: string, chainId: number): Promise { + // Validate it's a, well, valid address + const formatted = isAddress(account) + if (!formatted) return Promise.reject(new Error('Invalid address')) + + // To be sure, let's lowercase the hashed address and work with it instead + const lowerCasedAddress = formatted.toLowerCase() + + const claimKey = getClaimKey(lowerCasedAddress, chainId) + + return ( + FETCH_CLAIM_PROMISES[claimKey] ?? + (FETCH_CLAIM_PROMISES[claimKey] = fetchClaimsMapping(chainId) + .then((mapping) => { + const sorted = Object.keys(mapping).sort((a, b) => (a < b ? -1 : 1)) + + for (const startingAddress of sorted) { + const lastAddress = mapping[startingAddress] + if (startingAddress <= lowerCasedAddress) { + if (lowerCasedAddress <= lastAddress) { + return startingAddress + } + } else { + throw new Error(`Claim for ${claimKey} was not found in partial search`) + } + } + throw new Error(`Claim for ${claimKey} was not found after searching all mappings`) + }) + .then((address) => fetchClaimsFile(address, chainId)) + .then((result) => { + if (result[lowerCasedAddress]) return transformRepoClaimsToUserClaims(result[lowerCasedAddress]) // mod + throw new Error(`Claim for ${claimKey} was not found in claim file!`) + }) + .catch((error) => { + console.debug(`Claim fetch failed for ${claimKey}`, error) + throw error + })) + ) +} + +export function useClaimDispatchers() { + const dispatch = useDispatch() + + return useMemo( + () => ({ + // account + setInputAddress: (payload: string) => dispatch(setInputAddress(payload)), + setActiveClaimAccount: (payload: string) => dispatch(setActiveClaimAccount(payload)), + setActiveClaimAccountENS: (payload: string) => dispatch(setActiveClaimAccountENS(payload)), + // search + setIsSearchUsed: (payload: boolean) => dispatch(setIsSearchUsed(payload)), + // claiming + setClaimStatus: (payload: ClaimStatus) => dispatch(setClaimStatus(payload)), + setClaimedAmount: (payload: string) => dispatch(setClaimedAmount(payload)), + setEstimatedGas: (payload: string) => dispatch(setEstimatedGas(payload)), + // investing + setIsInvestFlowActive: (payload: boolean) => dispatch(setIsInvestFlowActive(payload)), + setInvestFlowStep: (payload: number) => dispatch(setInvestFlowStep(payload)), + initInvestFlowData: () => dispatch(initInvestFlowData()), + updateInvestAmount: (payload: { index: number; amount: string }) => dispatch(updateInvestAmount(payload)), + updateInvestError: (payload: { index: number; error: string | undefined }) => + dispatch(updateInvestError(payload)), + // claim row selection + setSelected: (payload: number[]) => dispatch(setSelected(payload)), + setSelectedAll: (payload: boolean) => dispatch(setSelectedAll(payload)), + // reset claim ui + resetClaimUi: () => dispatch(resetClaimUi()), + }), + [dispatch] + ) +} + +export function useClaimState() { + return useSelector((state: AppState) => state.claim) +} + +/** + * Returns a boolean indicating whehter there's an error on claim investment flow + */ +export function useHasClaimInvestmentFlowError(): boolean { + const { investFlowData } = useClaimState() + + return useMemo(() => { + return investFlowData.some(({ error }) => Boolean(error)) + }, [investFlowData]) +} + +/** + * Gets an array of available claims parsed and sorted for the UI + * + * Syntactic sugar on top of `useUserClaims` + * + * @param account + */ +export function useUserEnhancedClaimData(account: Account): EnhancedUserClaimData[] { + const { available } = useClassifiedUserClaims(account) + const { chainId: preCheckChainId } = useActiveWeb3React() + const native = useNativeTokenPrice() + const gno = useGnoPrice() + const usdc = useUsdcPrice() + + const sorted = useMemo(() => available.sort(_sortTypes), [available]) + + return useMemo(() => { + const chainId = supportedChainId(preCheckChainId) + if (!chainId || !native || !gno || !usdc) return [] + + return sorted.map((claim) => _enhanceClaimData(claim, chainId, { native, gno, usdc })) + }, [gno, native, preCheckChainId, sorted, usdc]) +} + +function _sortTypes(a: UserClaimData, b: UserClaimData): number { + return Number(isFreeClaim(b.type)) - Number(isFreeClaim(a.type)) +} + +function _enhanceClaimData(claim: UserClaimData, chainId: SupportedChainId, prices: VCowPrices): EnhancedUserClaimData { + const claimAmount = CurrencyAmount.fromRawAmount(ONE_VCOW.currency, claim.amount) + + const data: EnhancedUserClaimData = { + ...claim, + isFree: isFreeClaim(claim.type), + claimAmount, + } + + const tokenAndAmount = claimTypeToTokenAmount(claim.type, chainId, prices) + + // Free claims will have tokenAndAmount === undefined + // If it's not a free claim, store the price and calculate cost in investment token + if (tokenAndAmount?.amount) { + data.price = _getPrice(tokenAndAmount) + // get the currency amount using the price base currency (remember price was inverted) + data.currencyAmount = CurrencyAmount.fromRawAmount(data.price.baseCurrency, claim.amount) + + // e.g 1000 vCow / 20 GNO = 50 GNO cost + data.cost = data.currencyAmount.divide(data.price) + } + + return data +} + +function _getPrice({ token, amount }: { amount: string; token: Token | GpEther }) { + return new Price({ + baseAmount: ONE_VCOW, + quoteAmount: CurrencyAmount.fromRawAmount(token, amount), + }).invert() +} diff --git a/src/custom/state/claim/hooks/mocks/claimData.ts b/src/custom/state/claim/hooks/mocks/claimData.ts new file mode 100644 index 000000000..089fdfe75 --- /dev/null +++ b/src/custom/state/claim/hooks/mocks/claimData.ts @@ -0,0 +1,153 @@ +import { RepoClaimData } from '..' + +const mockData: Record = { + // airdrops + investments + '0xf17aFe5237D982868B8A97424dD79a4A50c36412': [ + { + proof: [ + '0x85bb2d293209f2ef10959e033b3d43c6d67058717f7b0a70568ca7d028b3592d', + '0x045442670919da3b5ce18ccc62308d670555184af497be8907560ff83e96fbc9', + '0x49535c52497395e0f14b85ebe0900de58f0809aca5d54de44b6e1ad4a00868b5', + '0xb3c22776e4694752f3f4d70f4a83fa50457b3df2c383b36fa26043dde76474cc', + '0x23b39cfa6ca6ae97ba67daa849e6497e0e89fbf997eabbd31fe8af7e54544967', + '0x1e88526debfd1b5c3955fc6c3facdbda5a99ba2cb1144d6cb7f21075c4becdf4', + '0x82c926e522b6eeca8139d31cfb200b1af4e4c02e0992d25125fe7ec8c8e15feb', + '0xac5661c776a0808e457967488a00e26d4ccf3e50426fc83a143d9150ae4f058a', + ], + index: 111, + type: 'Airdrop', + amount: '3925000000000000000000', + }, + { + proof: [ + '0x9ee4da15b49ddee8590aba5a89c4f137c3574920df8a44a52e6b88e420d068c7', + '0xbc4e08f8cbd18bdc713aa11f6cb5f5b3ea4d5ddccdaed57118d45899c83aa8ff', + '0x47591e84986f34a393c100f30219f4ba72c941053a598a1091644f9ee2920065', + '0x3cbbad2cfe9bdb3dd08d7f31edf23a0e7b33386d28ef5adeb3601289315e4a69', + '0xa168e9a8fd611fc5a6b83d06677a0a9fee5cc1b554a5dc3f5ed2485463901447', + '0xb27279799e2cfd94f9296ab300661ab7b938882420878f87b1fb0dc0d0d4ac35', + '0x82c926e522b6eeca8139d31cfb200b1af4e4c02e0992d25125fe7ec8c8e15feb', + '0xac5661c776a0808e457967488a00e26d4ccf3e50426fc83a143d9150ae4f058a', + ], + index: 112, + type: 'GnoOption', + amount: '3925000000000000000000', + }, + { + proof: [ + '0x9ee4da15b49ddee8590aba5a89c4f137c3574920df8a44a52e6b88e420d068c7', + '0xbc4e08f8cbd18bdc713aa11f6cb5f5b3ea4d5ddccdaed57118d45899c83aa8ff', + '0x47591e84986f34a393c100f30219f4ba72c941053a598a1091644f9ee2920065', + '0x3cbbad2cfe9bdb3dd08d7f31edf23a0e7b33386d28ef5adeb3601289315e4a69', + '0xa168e9a8fd611fc5a6b83d06677a0a9fee5cc1b554a5dc3f5ed2485463901447', + '0xb27279799e2cfd94f9296ab300661ab7b938882420878f87b1fb0dc0d0d4ac35', + '0x82c926e522b6eeca8139d31cfb200b1af4e4c02e0992d25125fe7ec8c8e15feb', + '0xac5661c776a0808e457967488a00e26d4ccf3e50426fc83a143d9150ae4f058a', + ], + index: 113, + type: 'Team', + amount: '5925000000000000000000', + }, + { + proof: [ + '0x9ee4da15b49ddee8590aba5a89c4f137c3574920df8a44a52e6b88e420d068c7', + '0xbc4e08f8cbd18bdc713aa11f6cb5f5b3ea4d5ddccdaed57118d45899c83aa8ff', + '0x47591e84986f34a393c100f30219f4ba72c941053a598a1091644f9ee2920065', + '0x3cbbad2cfe9bdb3dd08d7f31edf23a0e7b33386d28ef5adeb3601289315e4a69', + '0xa168e9a8fd611fc5a6b83d06677a0a9fee5cc1b554a5dc3f5ed2485463901447', + '0xb27279799e2cfd94f9296ab300661ab7b938882420878f87b1fb0dc0d0d4ac35', + '0x82c926e522b6eeca8139d31cfb200b1af4e4c02e0992d25125fe7ec8c8e15feb', + '0xac5661c776a0808e457967488a00e26d4ccf3e50426fc83a143d9150ae4f058a', + ], + index: 114, + type: 'Investor', + amount: '5925000000000000000000', + }, + { + proof: [ + '0x9ee4da15b49ddee8590aba5a89c4f137c3574920df8a44a52e6b88e420d068c7', + '0xbc4e08f8cbd18bdc713aa11f6cb5f5b3ea4d5ddccdaed57118d45899c83aa8ff', + '0x47591e84986f34a393c100f30219f4ba72c941053a598a1091644f9ee2920065', + '0x3cbbad2cfe9bdb3dd08d7f31edf23a0e7b33386d28ef5adeb3601289315e4a69', + '0xa168e9a8fd611fc5a6b83d06677a0a9fee5cc1b554a5dc3f5ed2485463901447', + '0xb27279799e2cfd94f9296ab300661ab7b938882420878f87b1fb0dc0d0d4ac35', + '0x82c926e522b6eeca8139d31cfb200b1af4e4c02e0992d25125fe7ec8c8e15feb', + '0xac5661c776a0808e457967488a00e26d4ccf3e50426fc83a143d9150ae4f058a', + ], + index: 115, + type: 'UserOption', + amount: '7925000000000000000000', + }, + { + proof: [ + '0x85bb2d293209f2ef10959e033b3d43c6d67058717f7b0a70568ca7d028b3592d', + '0x045442670919da3b5ce18ccc62308d670555184af497be8907560ff83e96fbc9', + '0x49535c52497395e0f14b85ebe0900de58f0809aca5d54de44b6e1ad4a00868b5', + '0xb3c22776e4694752f3f4d70f4a83fa50457b3df2c383b36fa26043dde76474cc', + '0x23b39cfa6ca6ae97ba67daa849e6497e0e89fbf997eabbd31fe8af7e54544967', + '0x1e88526debfd1b5c3955fc6c3facdbda5a99ba2cb1144d6cb7f21075c4becdf4', + '0x82c926e522b6eeca8139d31cfb200b1af4e4c02e0992d25125fe7ec8c8e15feb', + '0xac5661c776a0808e457967488a00e26d4ccf3e50426fc83a143d9150ae4f058a', + ], + index: 116, + type: 'Advisor', + amount: '3625000000000000000000', + }, + ], + + // only airdrops + '0x677aB2aa230CAce6456DAf045f259B8D8bC6DB04': [ + { + proof: [ + '0x85bb2d293209f2ef10959e033b3d43c6d67058717f7b0a70568ca7d028b3592d', + '0x045442670919da3b5ce18ccc62308d670555184af497be8907560ff83e96fbc9', + '0x49535c52497395e0f14b85ebe0900de58f0809aca5d54de44b6e1ad4a00868b5', + '0xb3c22776e4694752f3f4d70f4a83fa50457b3df2c383b36fa26043dde76474cc', + '0x23b39cfa6ca6ae97ba67daa849e6497e0e89fbf997eabbd31fe8af7e54544967', + '0x1e88526debfd1b5c3955fc6c3facdbda5a99ba2cb1144d6cb7f21075c4becdf4', + '0x82c926e522b6eeca8139d31cfb200b1af4e4c02e0992d25125fe7ec8c8e15feb', + '0xac5661c776a0808e457967488a00e26d4ccf3e50426fc83a143d9150ae4f058a', + ], + index: 120, + type: 'Airdrop', + amount: '925000000000000000000', + }, + { + proof: [ + '0x9ee4da15b49ddee8590aba5a89c4f137c3574920df8a44a52e6b88e420d068c7', + '0xbc4e08f8cbd18bdc713aa11f6cb5f5b3ea4d5ddccdaed57118d45899c83aa8ff', + '0x47591e84986f34a393c100f30219f4ba72c941053a598a1091644f9ee2920065', + '0x3cbbad2cfe9bdb3dd08d7f31edf23a0e7b33386d28ef5adeb3601289315e4a69', + '0xa168e9a8fd611fc5a6b83d06677a0a9fee5cc1b554a5dc3f5ed2485463901447', + '0xb27279799e2cfd94f9296ab300661ab7b938882420878f87b1fb0dc0d0d4ac35', + '0x82c926e522b6eeca8139d31cfb200b1af4e4c02e0992d25125fe7ec8c8e15feb', + '0xac5661c776a0808e457967488a00e26d4ccf3e50426fc83a143d9150ae4f058a', + ], + index: 121, + type: 'Team', + amount: '3925000000000000000000', + }, + { + proof: [ + '0x9ee4da15b49ddee8590aba5a89c4f137c3574920df8a44a52e6b88e420d068c7', + '0xbc4e08f8cbd18bdc713aa11f6cb5f5b3ea4d5ddccdaed57118d45899c83aa8ff', + '0x47591e84986f34a393c100f30219f4ba72c941053a598a1091644f9ee2920065', + '0x3cbbad2cfe9bdb3dd08d7f31edf23a0e7b33386d28ef5adeb3601289315e4a69', + '0xa168e9a8fd611fc5a6b83d06677a0a9fee5cc1b554a5dc3f5ed2485463901447', + '0xb27279799e2cfd94f9296ab300661ab7b938882420878f87b1fb0dc0d0d4ac35', + '0x82c926e522b6eeca8139d31cfb200b1af4e4c02e0992d25125fe7ec8c8e15feb', + '0xac5661c776a0808e457967488a00e26d4ccf3e50426fc83a143d9150ae4f058a', + ], + index: 122, + type: 'Advisor', + amount: '3925000000000000000000', + }, + ], + + // no claims + '0x7C842Bf74359911aEe49bA021014B05265f951c6': [], +} +const mockKeys = Object.keys(mockData) +export const MOCK_INDICES = mockData[mockKeys[0]].map(({ index }) => index) + +export default mockData diff --git a/src/custom/state/claim/hooks/utils.ts b/src/custom/state/claim/hooks/utils.ts new file mode 100644 index 000000000..8760c4f03 --- /dev/null +++ b/src/custom/state/claim/hooks/utils.ts @@ -0,0 +1,249 @@ +import { Currency, CurrencyAmount, Percent, Token } from '@uniswap/sdk-core' + +import { SupportedChainId } from 'constants/chains' +import { GNO, GpEther, USDC_BY_CHAIN, V_COW } from 'constants/tokens' +import { ONE_HUNDRED_PERCENT, ZERO_PERCENT } from 'constants/misc' + +import { + CLAIMS_REPO, + ClaimType, + ClaimTypePriceMap, + FREE_CLAIM_TYPES, + PAID_CLAIM_TYPES, + RepoClaims, + TypeToPriceMapper, + UserClaims, + VCowPrices, +} from 'state/claim/hooks/index' + +import { EnhancedUserClaimData, InvestmentAmounts } from 'pages/Claim/types' +import { InvestClaim } from 'state/claim/reducer' +import { ClaimInput } from 'state/claim/hooks/index' + +/** + * Helper function to check whether any claim is an investment option + * + * @param claims + */ +export function hasPaidClaim(claims: UserClaims | null): boolean { + return Boolean(claims?.some((claim) => PAID_CLAIM_TYPES.includes(claim.type))) +} + +/** + * Helper function to check whether any claim is an airdrop option + * + * @param claims + */ +export function hasFreeClaim(claims: UserClaims | null): boolean { + return Boolean(claims?.some((claim) => FREE_CLAIM_TYPES.includes(claim.type))) +} + +/** + * Helper function to transform data as coming from the airdrop claims repo onto internal types + * + * Namely, converting types from their string representations to the enum numbers: + * Airdrop -> 0 + */ +export function transformRepoClaimsToUserClaims(repoClaims: RepoClaims): UserClaims { + return repoClaims.map((claim) => ({ ...claim, type: ClaimType[claim.type] })) +} + +/** + * Helper function to return an array of investment option claims + * + * @param claims + */ +export function getPaidClaims(claims: UserClaims): UserClaims { + return claims?.filter((claim) => PAID_CLAIM_TYPES.includes(claim.type)) +} + +/** + * Helper function to return an array of free claims + * + * @param claims + */ +export function getFreeClaims(claims: UserClaims): UserClaims { + return claims?.filter((claim) => FREE_CLAIM_TYPES.includes(claim.type)) +} + +/** + * Helper function to transform claim data amount to CurrencyAmount + * + */ +export function parseClaimAmount(value: string, chainId: number | undefined): CurrencyAmount | undefined { + const vCow = chainId ? V_COW[chainId || 4] : undefined + if (!vCow || !value) return undefined + return CurrencyAmount.fromRawAmount(vCow, value) +} + +export type TypeToCurrencyMapper = { + [key: string]: string +} + +/** + * Helper function to transform claim data type to coin name that can be displayed in the UI + * + * @param chainId + */ +export function getTypeToCurrencyMap(chainId: number | undefined): TypeToCurrencyMapper { + if (!chainId) return {} + + const map: TypeToCurrencyMapper = { + [ClaimType.GnoOption]: 'GNO', + [ClaimType.Investor]: 'USDC', + [ClaimType.UserOption]: '', + } + + if ([SupportedChainId.MAINNET, SupportedChainId.RINKEBY].includes(chainId)) { + map[ClaimType.UserOption] = 'ETH' + } + + if (chainId === SupportedChainId.XDAI) { + map[ClaimType.UserOption] = 'XDAI' + } + + return map +} + +/** + * Helper function to get vCow price based on claim type and chainId + * + * @param type + */ +export function getTypeToPriceMap(): TypeToPriceMapper { + return ClaimTypePriceMap +} + +/** + * Helper function to check if current type is free claim + * + * @param type + */ +export function isFreeClaim(type: ClaimType): boolean { + return FREE_CLAIM_TYPES.includes(type) +} + +/** + * Helper function to return an array of indexes from claim data + * + * @param type + */ +export function getIndexes(data: RepoClaims | UserClaims): number[] { + return data.map(({ index }) => index) +} + +/** + * Helper function to get the repo path for the corresponding network id + * Throws when passed an unknown network id + */ +export function getClaimsRepoPath(id: SupportedChainId): string { + return `${CLAIMS_REPO}${_repoNetworkIdMapping(id)}/` +} + +function _repoNetworkIdMapping(id: SupportedChainId): string { + switch (id) { + case SupportedChainId.MAINNET: + return 'mainnet' + case SupportedChainId.RINKEBY: + return 'rinkeby' + case SupportedChainId.XDAI: + return 'gnosis-chain' + default: + throw new Error('Network not supported') + } +} + +/** + * Helper function to get the claim key based on account and chainId + */ +export function getClaimKey(account: string, chainId: number): string { + return `${chainId}:${account}` +} + +export type PaidClaimTypeToPriceMap = { + [type in ClaimType]: { token: Token; amount: string } | undefined +} + +export function claimTypeToToken(type: ClaimType, chainId: SupportedChainId) { + switch (type) { + case ClaimType.GnoOption: + return GNO[chainId] + case ClaimType.Investor: + return USDC_BY_CHAIN[chainId] + case ClaimType.UserOption: + return GpEther.onChain(chainId) + case ClaimType.Advisor: + case ClaimType.Airdrop: + case ClaimType.Team: + return undefined + } +} + +/** + * Helper function to get vCow price based on claim type and chainId + */ +export function claimTypeToTokenAmount(type: ClaimType, chainId: SupportedChainId, prices: VCowPrices) { + switch (type) { + case ClaimType.GnoOption: + return { token: claimTypeToToken(ClaimType.GnoOption, chainId) as Token, amount: prices.gno as string } + case ClaimType.Investor: + return { token: claimTypeToToken(ClaimType.Investor, chainId) as Token, amount: prices.usdc as string } + case ClaimType.UserOption: + return { token: claimTypeToToken(ClaimType.UserOption, chainId) as GpEther, amount: prices.native as string } + default: + return undefined + } +} + +/** + * Helper function to calculate and return the percentage between 2 CurrencyAmount instances + */ +export function calculatePercentage( + numerator: CurrencyAmount, + denominator: CurrencyAmount +): Percent { + let percentage = denominator.equalTo(ZERO_PERCENT) + ? ZERO_PERCENT + : new Percent(numerator.quotient, denominator.quotient) + if (percentage.greaterThan(ONE_HUNDRED_PERCENT)) { + percentage = ONE_HUNDRED_PERCENT + } + return percentage +} + +/** + * Helper function that calculates vCowAmount (in vCOW) and investedAmount (in investing token) + */ +export function calculateInvestmentAmounts( + claim: Pick, + investedAmount?: string +): InvestmentAmounts { + const { isFree, price, currencyAmount, claimAmount } = claim + + if (isFree || !investedAmount) { + // default to 100% when no investment amount is set + return { vCowAmount: claimAmount, investmentCost: currencyAmount } + } else if (!currencyAmount || !price) { + return {} + } + + const amount = CurrencyAmount.fromRawAmount(currencyAmount.currency, investedAmount) + return { vCowAmount: price.quote(amount), investmentCost: amount } +} + +/** + * Helper function that prepares investFlowData for claiming by calculating vCowAmount from investedAmounts + */ +export function prepareInvestClaims(investFlowData: InvestClaim[], userClaimData: EnhancedUserClaimData[]) { + return investFlowData.reduce((acc, { index, investedAmount }) => { + const claim = userClaimData.find(({ index: idx }) => idx === index) + + if (claim) { + const { vCowAmount } = calculateInvestmentAmounts(claim, investedAmount) + + acc.push({ index, amount: vCowAmount?.quotient.toString() }) + } + + return acc + }, []) +} diff --git a/src/custom/state/claim/middleware.ts b/src/custom/state/claim/middleware.ts new file mode 100644 index 000000000..9951e5fba --- /dev/null +++ b/src/custom/state/claim/middleware.ts @@ -0,0 +1,41 @@ +import { Middleware, isAnyOf } from '@reduxjs/toolkit' +import { getCowSoundSuccess, getCowSoundSend } from 'utils/sound' +import { AppState } from 'state' +import { finalizeTransaction, addTransaction } from '../enhancedTransactions/actions' +import { setClaimStatus, ClaimStatus } from './actions' + +const isFinalizeTransaction = isAnyOf(finalizeTransaction) +const isAddTransaction = isAnyOf(addTransaction) + +// Watch for claim tx being finalized and triggers a change of status +export const claimMinedMiddleware: Middleware, AppState> = (store) => (next) => (action) => { + const result = next(action) + + let cowSound + if (isAddTransaction(action)) { + const { chainId, hash } = action.payload + const transaction = store.getState().transactions[chainId][hash] + + if (transaction.claim) { + console.debug('[stat:claim:middleware] Claim transaction sent', transaction.hash, transaction.claim) + cowSound = getCowSoundSend() + } + } else if (isFinalizeTransaction(action)) { + const { chainId, hash } = action.payload + const transaction = store.getState().transactions[chainId][hash] + + if (transaction.claim) { + console.debug('[stat:claim:middleware] Claim transaction finalized', transaction.hash, transaction.claim) + store.dispatch(setClaimStatus(ClaimStatus.CONFIRMED)) + cowSound = getCowSoundSuccess() + } + } + + if (cowSound) { + cowSound.play().catch((e) => { + console.error('🐮 [Claiming] Moooooo cannot be played', e) + }) + } + + return result +} diff --git a/src/custom/state/claim/reducer.ts b/src/custom/state/claim/reducer.ts new file mode 100644 index 000000000..1dfd7baba --- /dev/null +++ b/src/custom/state/claim/reducer.ts @@ -0,0 +1,129 @@ +import { createReducer, current } from '@reduxjs/toolkit' +import { + setActiveClaimAccount, + setActiveClaimAccountENS, + setClaimStatus, + setClaimedAmount, + setInputAddress, + setInvestFlowStep, + setIsInvestFlowActive, + initInvestFlowData, + updateInvestAmount, + setIsSearchUsed, + setSelected, + setSelectedAll, + resetClaimUi, + ClaimStatus, + updateInvestError, + setEstimatedGas, +} from './actions' + +export const initialState: ClaimState = { + // address/ENS address + inputAddress: '', + // account + activeClaimAccount: '', + activeClaimAccountENS: '', + // check address + isSearchUsed: false, + // claiming + claimStatus: ClaimStatus.DEFAULT, + claimedAmount: '', + estimatedGas: '', + // investment + isInvestFlowActive: false, + investFlowStep: 0, + investFlowData: [], + // table select change + selected: [], + selectedAll: false, +} + +export type InvestClaim = { + index: number + investedAmount: string + error?: string +} + +export type ClaimState = { + // address/ENS address + inputAddress: string + // account + activeClaimAccount: string + activeClaimAccountENS: string + // check address + isSearchUsed: boolean + // claiming + claimStatus: ClaimStatus + claimedAmount: string + estimatedGas: string + // investment + isInvestFlowActive: boolean + investFlowStep: number + investFlowData: InvestClaim[] + // table select change + selected: number[] + selectedAll: boolean +} + +export default createReducer(initialState, (builder) => + builder + .addCase(setInputAddress, (state, { payload }) => { + state.inputAddress = payload + }) + .addCase(setActiveClaimAccount, (state, { payload }) => { + state.activeClaimAccount = payload + }) + .addCase(setActiveClaimAccountENS, (state, { payload }) => { + state.activeClaimAccountENS = payload + }) + .addCase(setIsSearchUsed, (state, { payload }) => { + state.isSearchUsed = payload + }) + .addCase(setClaimStatus, (state, { payload }) => { + state.claimStatus = payload + }) + .addCase(setClaimedAmount, (state, { payload }) => { + state.claimedAmount = payload + }) + .addCase(setEstimatedGas, (state, { payload }) => { + state.estimatedGas = payload + }) + .addCase(setIsInvestFlowActive, (state, { payload }) => { + state.isInvestFlowActive = payload + }) + .addCase(setInvestFlowStep, (state, { payload }) => { + state.investFlowStep = payload + }) + .addCase(initInvestFlowData, (state) => { + const { selected, isInvestFlowActive } = current(state) + + const data = selected.map((index) => ({ index, investedAmount: '0' })) + + if (isInvestFlowActive) { + state.investFlowData.push(...data) + } else { + state.investFlowData.length = 0 + } + }) + .addCase(updateInvestAmount, (state, { payload: { index, amount } }) => { + state.investFlowData[index].investedAmount = amount + }) + .addCase(updateInvestError, (state, { payload: { index, error } }) => { + state.investFlowData[index].error = error + }) + .addCase(setSelected, (state, { payload }) => { + state.selected = payload + }) + .addCase(setSelectedAll, (state, { payload }) => { + state.selectedAll = payload + }) + .addCase(resetClaimUi, (state) => { + state.selected = initialState.selected + state.selectedAll = initialState.selectedAll + state.investFlowStep = initialState.investFlowStep + state.isInvestFlowActive = initialState.isInvestFlowActive + state.claimedAmount = initialState.claimedAmount + state.estimatedGas = initialState.estimatedGas + }) +) diff --git a/src/custom/state/enhancedTransactions/actions.ts b/src/custom/state/enhancedTransactions/actions.ts index d1a77cfa5..00bd2c90d 100644 --- a/src/custom/state/enhancedTransactions/actions.ts +++ b/src/custom/state/enhancedTransactions/actions.ts @@ -4,11 +4,13 @@ import { SerializableTransactionReceipt } from '@src/state/transactions/actions' import { EnhancedTransactionDetails } from './reducer' type WithChainId = { chainId: number } +type WithData = { data?: any } export type AddTransactionParams = WithChainId & + WithData & Pick< EnhancedTransactionDetails, - 'hash' | 'hashType' | 'from' | 'approval' | 'presign' | 'summary' | 'safeTransaction' + 'hash' | 'hashType' | 'from' | 'approval' | 'presign' | 'claim' | 'summary' | 'safeTransaction' > export const addTransaction = createAction('enhancedTransactions/addTransaction') diff --git a/src/custom/state/enhancedTransactions/hooks/index.ts b/src/custom/state/enhancedTransactions/hooks/index.ts index 888c95afe..ced781e23 100644 --- a/src/custom/state/enhancedTransactions/hooks/index.ts +++ b/src/custom/state/enhancedTransactions/hooks/index.ts @@ -24,12 +24,25 @@ export function useTransactionAdder(): TransactionAdder { (addTransactionParams: AddTransactionHookParams) => { if (!account || !chainId) return - const { hash, summary, approval, presign, safeTransaction } = addTransactionParams + const { hash, summary, data, claim, approval, presign, safeTransaction } = addTransactionParams const hashType = isGnosisSafeWallet ? HashType.GNOSIS_SAFE_TX : HashType.ETHEREUM_TX if (!hash) { throw Error('No transaction hash found') } - dispatch(addTransaction({ hash, hashType, from: account, chainId, approval, summary, presign, safeTransaction })) + dispatch( + addTransaction({ + hash, + hashType, + from: account, + chainId, + approval, + summary, + claim, + data, + presign, + safeTransaction, + }) + ) }, [dispatch, chainId, account, isGnosisSafeWallet] ) @@ -105,3 +118,26 @@ export function useTransactionsByHash({ hashes }: { hashes: string[] }): Enhance }, {}) }, [allTxs, hashes]) } + +export function useAllClaimingTransactions() { + const transactionsMap = useAllTransactions() + const transactions = Object.values(transactionsMap) + + return useMemo(() => { + return transactions.filter((tx) => !!tx.claim) + }, [transactions]) +} + +export function useAllClaimingTransactionIndices() { + const claimingTransactions = useAllClaimingTransactions() + return useMemo(() => { + const flattenedClaimingTransactions = claimingTransactions.reduce((acc, { claim }) => { + if (claim && claim.indices) { + acc.push(...claim.indices) + } + return acc + }, []) + + return new Set(flattenedClaimingTransactions) + }, [claimingTransactions]) +} diff --git a/src/custom/state/enhancedTransactions/reducer.ts b/src/custom/state/enhancedTransactions/reducer.ts index 4b41cbb52..0cbe587d3 100644 --- a/src/custom/state/enhancedTransactions/reducer.ts +++ b/src/custom/state/enhancedTransactions/reducer.ts @@ -30,10 +30,12 @@ export interface EnhancedTransactionDetails { summary?: string confirmedTime?: number receipt?: SerializableTransactionReceipt // Ethereum transaction receipt + data?: any // any attached data type // Operations approval?: { tokenAddress: string; spender: string } presign?: { orderId: string } + claim?: { recipient: string; cowAmountRaw?: string; indices: number[] } // Wallet specific safeTransaction?: SafeMultisigTransactionResponse // Gnosis Safe transaction info @@ -62,7 +64,10 @@ export default createReducer(initialState, (builder) => builder .addCase( addTransaction, - (transactions, { payload: { chainId, from, hash, hashType, approval, summary, presign, safeTransaction } }) => { + ( + transactions, + { payload: { chainId, from, hash, hashType, approval, summary, presign, safeTransaction, claim, data } } + ) => { if (transactions[chainId]?.[hash]) { console.warn('[state::enhancedTransactions] Attempted to add existing transaction', hash) // Unknown transaction. Do nothing! @@ -76,11 +81,13 @@ export default createReducer(initialState, (builder) => addedTime: now(), from, summary, + data, // Operations approval, presign, safeTransaction, + claim, } transactions[chainId] = txs } diff --git a/src/custom/state/index.ts b/src/custom/state/index.ts index 640f00908..d0dd2a00e 100644 --- a/src/custom/state/index.ts +++ b/src/custom/state/index.ts @@ -23,8 +23,10 @@ import profile from 'state/profile/reducer' import { updateVersion } from 'state/global/actions' import affiliate from 'state/affiliate/reducer' import enhancedTransactions from 'state/enhancedTransactions/reducer' +import claim from 'state/claim/reducer' import { popupMiddleware, soundMiddleware } from './orders/middleware' +import { claimMinedMiddleware } from './claim/middleware' import { DEFAULT_NETWORK_FOR_LISTS } from 'constants/lists' const UNISWAP_REDUCERS = { @@ -51,6 +53,7 @@ const reducers = { gas, affiliate, profile, + claim, } const PERSISTED_KEYS: string[] = ['user', 'transactions', 'orders', 'lists', 'gas', 'affiliate', 'profile'] @@ -63,6 +66,7 @@ const store = configureStore({ .concat(routingApi.middleware) .concat(save({ states: PERSISTED_KEYS, debounce: 1000 })) .concat(popupMiddleware) + .concat(claimMinedMiddleware) .concat(soundMiddleware), preloadedState: load({ states: PERSISTED_KEYS, disableWarnings: process.env.NODE_ENV === 'test' }), }) diff --git a/src/custom/state/orders/middleware.ts b/src/custom/state/orders/middleware.ts index 971b636fa..b612cbb87 100644 --- a/src/custom/state/orders/middleware.ts +++ b/src/custom/state/orders/middleware.ts @@ -6,17 +6,7 @@ import * as OrderActions from './actions' import { OrderIDWithPopup, OrderTxTypes, PopupPayload, buildCancellationPopupSummary, setPopupData } from './helpers' import { registerOnWindow } from 'utils/misc' - -type SoundType = 'SEND' | 'SUCCESS' | 'ERROR' -type Sounds = Record - -const COW_SOUNDS: Sounds = { - SEND: '/audio/mooooo-send__lower-90.mp3', - SUCCESS: '/audio/mooooo-success__ben__lower-90.mp3', - ERROR: '/audio/mooooo-error__lower-90.mp3', -} - -const SOUND_CACHE: Record = {} +import { getCowSoundError, getCowSoundSend, getCowSoundSuccess } from 'utils/sound' // action syntactic sugar const isSingleOrderChangeAction = isAnyOf( @@ -173,30 +163,6 @@ export const popupMiddleware: Middleware, AppState> = (s return result } -function getAudio(type: SoundType): HTMLAudioElement { - const soundPath = COW_SOUNDS[type] - let sound = SOUND_CACHE[soundPath] - - if (!sound) { - sound = new Audio(soundPath) - SOUND_CACHE[soundPath] = sound - } - - return sound -} - -function getCowSoundSend(): HTMLAudioElement { - return getAudio('SEND') -} - -function getCowSoundSuccess(): HTMLAudioElement { - return getAudio('SUCCESS') -} - -function getCowSoundError(): HTMLAudioElement { - return getAudio('ERROR') -} - function removeLightningEffect() { document.body.classList.remove('lightning') } diff --git a/src/custom/state/swap/extension.ts b/src/custom/state/swap/extension.ts index a15d5a440..082644396 100644 --- a/src/custom/state/swap/extension.ts +++ b/src/custom/state/swap/extension.ts @@ -15,6 +15,12 @@ interface TradeParams { export const stringToCurrency = (amount: string, currency: Currency) => CurrencyAmount.fromRawAmount(currency, JSBI.BigInt(amount)) +export const tryAtomsToCurrency = (atoms: string | undefined, currency: Currency | undefined) => { + if (!atoms || !currency) return undefined + + return stringToCurrency(atoms, currency) +} + /** * useTradeExactInWithFee * @description wraps useTradeExactIn and returns an extended trade object with the fee adjusted values diff --git a/src/custom/state/transactions/hooks.ts b/src/custom/state/transactions/hooks.ts new file mode 100644 index 000000000..c0c578b16 --- /dev/null +++ b/src/custom/state/transactions/hooks.ts @@ -0,0 +1,22 @@ +import { useMemo } from 'react' +import { useAllClaimingTransactions } from 'state/enhancedTransactions/hooks' +import { EnhancedTransactionDetails } from 'state/enhancedTransactions/reducer' + +export * from '@src/state/transactions/hooks' + +// watch for submissions to claim +// return null if not done loading, return undefined if not found +export function useUserHasSubmittedClaim(account?: string): { + claimSubmitted: boolean + claimTxn: EnhancedTransactionDetails | undefined +} { + const pendingClaims = useAllClaimingTransactions() + const claimTxn = useMemo( + () => + // find one that is both the user's claim, AND not mined + pendingClaims.find((claim) => claim.claim?.recipient === account), + [account, pendingClaims] + ) + + return { claimSubmitted: !!claimTxn, claimTxn } +} diff --git a/src/custom/theme/baseTheme.tsx b/src/custom/theme/baseTheme.tsx index 3c0a57f52..48fe4f92c 100644 --- a/src/custom/theme/baseTheme.tsx +++ b/src/custom/theme/baseTheme.tsx @@ -67,6 +67,7 @@ export function colors(darkMode: boolean): Colors { greenShade: '#376c57', blueShade: '#0f2644', blueShade2: '#011e34', + blueShade3: darkMode ? '#1c416e' : '#bdd6e1', // states success: darkMode ? '#00d897' : '#00815a', @@ -158,6 +159,14 @@ export function themeVariables(darkMode: boolean, colorsTheme: Colors) { inset -2px 2px 4px ${darkMode ? '#021E34' : 'rgb(162 200 216)'}; `, }, + cowToken: { + background: css` + background: linear-gradient(70.89deg, #292a30 10.71%, #101015 33%, #0e0501 88.54%); + `, + boxShadow: css` + box-shadow: inset 1px 0px 1px -1px hsla(0, 0%, 100%, 0.4); + `, + }, card: { background: css` background: linear-gradient(145deg, ${colorsTheme.bg3}, ${colorsTheme.bg4}); @@ -172,6 +181,9 @@ export function themeVariables(darkMode: boolean, colorsTheme: Colors) { box-shadow: inset 0 1px 1px 0 hsl(0deg 0% 100% / 10%), 0 10px 40px -20px #000000; `, }, + iconGradientBorder: css` + background: conic-gradient(${colorsTheme.bg3} 40grad, 80grad, ${colorsTheme.primary1} 360grad); + `, header: { border: 'none', menuFlyout: { diff --git a/src/custom/theme/styled.d.ts b/src/custom/theme/styled.d.ts index b8a4adfa7..207d110b6 100644 --- a/src/custom/theme/styled.d.ts +++ b/src/custom/theme/styled.d.ts @@ -10,6 +10,7 @@ export interface Colors extends ColorsUniswap { greenShade: Color blueShade: Color blueShade2: Color + blueShade3: Color success: Color danger: Color pending: Color @@ -81,6 +82,11 @@ declare module 'styled-components' { neumorphism: { boxShadow: FlattenSimpleInterpolation } + cowToken: { + background: FlattenSimpleInterpolation + boxShadow: FlattenSimpleInterpolation + }, + iconGradientBorder: FlattenSimpleInterpolation card: { background: FlattenSimpleInterpolation background2: string diff --git a/src/custom/utils/format.ts b/src/custom/utils/format.ts index b5e300368..3028feb3e 100644 --- a/src/custom/utils/format.ts +++ b/src/custom/utils/format.ts @@ -139,6 +139,11 @@ export function formatSmart( }) } +export function formatSmartLocaleAware(...params: Parameters): ReturnType { + const [value, decimalsToShow, options = {}] = params + return formatSmart(value, decimalsToShow, { ...options, isLocaleAware: true, thousandSeparator: true }) +} + /** * Formats Fraction with max precision * diff --git a/src/custom/utils/sound.ts b/src/custom/utils/sound.ts new file mode 100644 index 000000000..58c6445d4 --- /dev/null +++ b/src/custom/utils/sound.ts @@ -0,0 +1,34 @@ +type SoundType = 'SEND' | 'SUCCESS' | 'ERROR' +type Sounds = Record + +const COW_SOUNDS: Sounds = { + SEND: '/audio/mooooo-send__lower-90.mp3', + SUCCESS: '/audio/mooooo-success__ben__lower-90.mp3', + ERROR: '/audio/mooooo-error__lower-90.mp3', +} + +const SOUND_CACHE: Record = {} + +function getAudio(type: SoundType): HTMLAudioElement { + const soundPath = COW_SOUNDS[type] + let sound = SOUND_CACHE[soundPath] + + if (!sound) { + sound = new Audio(soundPath) + SOUND_CACHE[soundPath] = sound + } + + return sound +} + +export function getCowSoundSend(): HTMLAudioElement { + return getAudio('SEND') +} + +export function getCowSoundSuccess(): HTMLAudioElement { + return getAudio('SUCCESS') +} + +export function getCowSoundError(): HTMLAudioElement { + return getAudio('ERROR') +} diff --git a/src/custom/utils/time.ts b/src/custom/utils/time.ts index bfb85f7e6..c276c0ed7 100644 --- a/src/custom/utils/time.ts +++ b/src/custom/utils/time.ts @@ -5,3 +5,16 @@ export function getDateTimestamp(date: Date): number { return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() } + +/** + * Helper function that returns a given Date/timestamp as a locale representation of it as string + * in the format (