diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..adad245 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "protocol/core/lib/forge-std"] + path = protocol/core/lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "protocol/core/lib/permit2"] + path = protocol/core/lib/permit2 + url = https://github.com/Uniswap/permit2 +[submodule "protocol/core/lib/openzeppelin-contracts"] + path = protocol/core/lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/.npmrc b/.npmrc deleted file mode 100644 index ded82e2..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -auto-install-peers = true diff --git a/apps/docs/.eslintrc.js b/apps/docs/.eslintrc.js deleted file mode 100644 index c8df607..0000000 --- a/apps/docs/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: ["custom"], -}; diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore deleted file mode 100644 index 1437c53..0000000 --- a/apps/docs/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env.local -.env.development.local -.env.test.local -.env.production.local - -# vercel -.vercel diff --git a/apps/docs/README.md b/apps/docs/README.md deleted file mode 100644 index 4fae62a..0000000 --- a/apps/docs/README.md +++ /dev/null @@ -1,30 +0,0 @@ -## Getting Started - -First, run the development server: - -```bash -yarn dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. - -[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. - -The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn/foundations/about-nextjs) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/apps/docs/next-env.d.ts b/apps/docs/next-env.d.ts deleted file mode 100644 index 4f11a03..0000000 --- a/apps/docs/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/docs/next.config.js b/apps/docs/next.config.js deleted file mode 100644 index fdda6aa..0000000 --- a/apps/docs/next.config.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - reactStrictMode: true, - transpilePackages: ["ui"], -}; diff --git a/apps/docs/package.json b/apps/docs/package.json deleted file mode 100644 index ba85ff8..0000000 --- a/apps/docs/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "docs", - "version": "1.0.0", - "private": true, - "scripts": { - "dev": "next dev --port 3001", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "next": "latest", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "ui": "workspace:*" - }, - "devDependencies": { - "@types/node": "^17.0.12", - "@types/react": "^18.0.22", - "@types/react-dom": "^18.0.7", - "eslint-config-custom": "workspace:*", - "tsconfig": "workspace:*", - "typescript": "^4.5.3" - } -} diff --git a/apps/docs/pages/index.tsx b/apps/docs/pages/index.tsx deleted file mode 100644 index 0d1dabc..0000000 --- a/apps/docs/pages/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Button } from "ui"; - -export default function Docs() { - return ( -
-

Docs

-
- ); -} diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json deleted file mode 100644 index a355365..0000000 --- a/apps/docs/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "tsconfig/nextjs.json", - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] -} diff --git a/apps/web/components/AppBar.tsx b/apps/web/components/AppBar.tsx new file mode 100644 index 0000000..aec0b98 --- /dev/null +++ b/apps/web/components/AppBar.tsx @@ -0,0 +1,20 @@ +import { Button, Navbar, Text } from "@nextui-org/react"; + +import { ConnectButton } from "@rainbow-me/rainbowkit"; + +export default function AppBar() { + return ( + + + + Disperse + + + + + + + + + ); +} diff --git a/apps/web/components/Send.tsx b/apps/web/components/Send.tsx new file mode 100644 index 0000000..5fb4e42 --- /dev/null +++ b/apps/web/components/Send.tsx @@ -0,0 +1,143 @@ +import { + erc20ABI, + useContract, + useContractRead, + useContractWrite, + usePrepareContractWrite, + useProvider, + useSigner, + Address, +} from "wagmi"; +import { Button } from "@nextui-org/react"; + +import { + // permit2 contract address + PERMIT2_ADDRESS, + // the type of permit that we need to sign + // this will help us get domain, types and values that we need to create a signature + AllowanceTransfer, + AllowanceProvider, + MaxUint48, + AllowanceData, +} from "@uniswap/permit2-sdk"; + +import disperseAbi from "../constants/abis/disperse"; +import { useEffect, useState } from "react"; +import { BigNumber } from "ethers"; +import { MaxUint256 } from "@uniswap/permit2-sdk"; + +const TOKEN_ADDRESS = "0xda5bb55c0eA3f77321A888CA202cb84aE30C6AF5"; +const DISPERSE_ADDRESS = "0xC5ac98C06391981A4802A31ca5C62e6c3EfdA48d"; + +interface PermitSingle { + details: { + token: `0x${string}`; + amount: BigNumber; + expiration: number; + nonce: number; + }; + spender: `0x${string}`; + sigDeadline: BigNumber; +} + +const Send = ({ + tokenAddress, + transferDetails, + isNative, + totalValue, +}: { + isNative: boolean; + tokenAddress: `0x${string}`; + transferDetails: { + recipients: Address[]; + values: string[]; + }; + totalValue: BigNumber; +}) => { + const token = useContract({ + address: tokenAddress, + abi: erc20ABI, + }); + + const [allowanceData, setAllowanceData] = useState( + null + ); + + const signer = useSigner(); + + const disperse = useContract({ + abi: disperseAbi, + address: DISPERSE_ADDRESS, + signerOrProvider: signer.data, + }); + + const provider = useProvider(); + + const handleClick = async () => { + if (!allowanceData) return; + + if (allowanceData.amount.lt(totalValue)) { + try { + await token?.approve(PERMIT2_ADDRESS, MaxUint256); + } catch { + return; + } + } + + const permit: PermitSingle = { + details: { + token: tokenAddress, + amount: BigNumber.from(1), + expiration: MaxUint48.toNumber(), + nonce: allowanceData.nonce, + }, + spender: disperse?.address || "0x", + sigDeadline: MaxUint48, + }; + + console.log(permit); + + const { domain, types, values } = AllowanceTransfer.getPermitData( + permit, + PERMIT2_ADDRESS, + 1337 + ); + //@ts-ignore + let signature = await signer.data._signTypedData(domain, types, values); + + const fire = await disperse?.disperseSingleWithPermit2( + tokenAddress, + transferDetails.recipients, + transferDetails.values.map((v) => BigNumber.from(v)), + permit, + signature + ); + }; + + useEffect(() => { + async function update() { + const allowanceProvider = new AllowanceProvider( + provider, + PERMIT2_ADDRESS + ); + + const allowanceData = await allowanceProvider.getAllowanceData( + "0xda5bb55c0eA3f77321A888CA202cb84aE30C6AF5", + "0xDe485812E28824e542B9c2270B6b8eD9232B7D0b", + "0xC5ac98C06391981A4802A31ca5C62e6c3EfdA48d" + ); + + setAllowanceData(allowanceData); + } + + update(); + }, [provider]); + + return ( + + ); +}; + +export default Send; diff --git a/apps/web/components/TokenInfo.tsx b/apps/web/components/TokenInfo.tsx new file mode 100644 index 0000000..42e809e --- /dev/null +++ b/apps/web/components/TokenInfo.tsx @@ -0,0 +1,42 @@ +import { useToken, erc20ABI, useContractRead, useAccount } from "wagmi"; +import { utils as ethersUtils } from "ethers"; + +const Balance = ({ contractAddress, userAddress, decimals }) => { + const balance = useContractRead({ + abi: erc20ABI, + address: contractAddress, + functionName: "balanceOf", + args: [userAddress], + }); + + if (balance.isLoading) return
Fetching token…
; + + return

Balance: {ethersUtils.formatUnits(balance.data, decimals)}

; +}; + +export default function TokenInfo({ address }: { address: `0x${string}` }) { + const { data, isError, isLoading } = useToken({ + address, + }); + + const account = useAccount(); + + console.log(data); + + if (isLoading) return
Fetching token…
; + if (isError) return
Error fetching token
; + + return ( +
+

Symbol: {data?.symbol}

+

Name: {data?.name}

+ {account.address ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/components/TransferInfo.tsx b/apps/web/components/TransferInfo.tsx new file mode 100644 index 0000000..2e0e051 --- /dev/null +++ b/apps/web/components/TransferInfo.tsx @@ -0,0 +1,55 @@ +import { + useContractReads, + useAccount, + erc20ABI, + Address, + useContractRead, +} from "wagmi"; +import { BigNumber, utils as ethersUtils } from "ethers"; + +export default function TransferInfo({ + address, + totalValue, +}: { + address: Address; + totalValue: BigNumber; +}) { + const account = useAccount(); + + const reads = useContractReads({ + contracts: [ + { + abi: erc20ABI, + address, + functionName: "decimals", + }, + { + abi: erc20ABI, + address, + functionName: "balanceOf", + args: [account?.address], + }, + ], + }); + + if (reads.isLoading || !reads.data) return
Loading
; + if (reads.isError) return
Error fetching
; + + console.log(reads.data[0]); + + return ( +
+

Total Value: {ethersUtils.formatUnits(totalValue, reads.data[0])}

+

+ Your Balance: {ethersUtils.formatUnits(reads.data[1], reads.data[0])} +

+

+ Remaining:{" "} + {ethersUtils.formatUnits( + reads.data[1].sub(totalValue.mul(reads.data[0])), + reads.data[0] + )} +

+
+ ); +} diff --git a/apps/web/constants/abis/disperse.ts b/apps/web/constants/abis/disperse.ts new file mode 100644 index 0000000..0817192 --- /dev/null +++ b/apps/web/constants/abis/disperse.ts @@ -0,0 +1,213 @@ +const disperseAbi = [ + { + inputs: [ + { + internalType: "address", + name: "permit2", + type: "address", + }, + ], + stateMutability: "nonpayable", + type: "constructor", + }, + { + inputs: [], + name: "PERMIT2", + outputs: [ + { + internalType: "contract IAllowanceTransfer", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address[]", + name: "tokens", + type: "address[]", + }, + { + internalType: "address[][]", + name: "recipients", + type: "address[][]", + }, + { + internalType: "uint256[][]", + name: "values", + type: "uint256[][]", + }, + { + components: [ + { + components: [ + { + internalType: "address", + name: "token", + type: "address", + }, + { + internalType: "uint160", + name: "amount", + type: "uint160", + }, + { + internalType: "uint48", + name: "expiration", + type: "uint48", + }, + { + internalType: "uint48", + name: "nonce", + type: "uint48", + }, + ], + internalType: "struct IAllowanceTransfer.PermitDetails[]", + name: "details", + type: "tuple[]", + }, + { + internalType: "address", + name: "spender", + type: "address", + }, + { + internalType: "uint256", + name: "sigDeadline", + type: "uint256", + }, + ], + internalType: "struct IAllowanceTransfer.PermitBatch", + name: "_permit", + type: "tuple", + }, + { + internalType: "bytes", + name: "_signature", + type: "bytes", + }, + ], + name: "disperseBatchWithPermit2", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address[]", + name: "recipients", + type: "address[]", + }, + { + internalType: "uint256[]", + name: "values", + type: "uint256[]", + }, + ], + name: "disperseNative", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "token", + type: "address", + }, + { + internalType: "address[]", + name: "recipients", + type: "address[]", + }, + { + internalType: "uint256[]", + name: "values", + type: "uint256[]", + }, + ], + name: "disperseSingle", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "token", + type: "address", + }, + { + internalType: "address[]", + name: "recipients", + type: "address[]", + }, + { + internalType: "uint256[]", + name: "values", + type: "uint256[]", + }, + { + components: [ + { + components: [ + { + internalType: "address", + name: "token", + type: "address", + }, + { + internalType: "uint160", + name: "amount", + type: "uint160", + }, + { + internalType: "uint48", + name: "expiration", + type: "uint48", + }, + { + internalType: "uint48", + name: "nonce", + type: "uint48", + }, + ], + internalType: "struct IAllowanceTransfer.PermitDetails", + name: "details", + type: "tuple", + }, + { + internalType: "address", + name: "spender", + type: "address", + }, + { + internalType: "uint256", + name: "sigDeadline", + type: "uint256", + }, + ], + internalType: "struct IAllowanceTransfer.PermitSingle", + name: "_permit", + type: "tuple", + }, + { + internalType: "bytes", + name: "_signature", + type: "bytes", + }, + ], + name: "disperseSingleWithPermit2", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +] as const; + +export default disperseAbi; diff --git a/apps/web/package.json b/apps/web/package.json index f1a4a37..463e845 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,17 +9,22 @@ "lint": "next lint" }, "dependencies": { + "@nextui-org/react": "1.0.0-beta.13", + "@rainbow-me/rainbowkit": "^0.12.12", + "@uniswap/permit2-sdk": "^1.2.0", + "ethers": "^5", "next": "latest", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "ui": "workspace:*" + "next-themes": "^0.2.1", + "react": "18.0.0", + "react-dom": "18.0.0", + "wagmi": "^0.12.13" }, "devDependencies": { "@types/node": "^17.0.12", "@types/react": "^18.0.22", "@types/react-dom": "^18.0.7", - "eslint-config-custom": "workspace:*", - "tsconfig": "workspace:*", + "eslint-config-custom": "*", + "tsconfig": "*", "typescript": "^4.5.3" } } diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx new file mode 100644 index 0000000..1fe070e --- /dev/null +++ b/apps/web/pages/_app.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import type { AppProps } from "next/app"; +import { createTheme, CssBaseline, NextUIProvider } from "@nextui-org/react"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import { + getDefaultWallets, + RainbowKitProvider, + darkTheme, +} from "@rainbow-me/rainbowkit"; +import { configureChains, createClient, WagmiConfig } from "wagmi"; +import { mainnet, polygon, optimism, arbitrum, localhost } from "wagmi/chains"; +import { publicProvider } from "wagmi/providers/public"; + +import "@rainbow-me/rainbowkit/styles.css"; + +const nextUiLightTheme = createTheme({ + type: "light", +}); + +const nextUiDarkTheme = createTheme({ + type: "dark", +}); + +const { chains, provider } = configureChains( + [mainnet, polygon, optimism, arbitrum, localhost], + [publicProvider()] +); + +const { connectors } = getDefaultWallets({ + appName: "disperse.xyz", + chains, +}); + +const wagmiClient = createClient({ + autoConnect: true, + connectors, + provider, +}); + +function App({ Component }: AppProps) { + return ( + + + + + {CssBaseline.flush()} + + + + + + ); +} + +export default App; diff --git a/apps/web/pages/_document.tsx b/apps/web/pages/_document.tsx new file mode 100644 index 0000000..b5d6ab0 --- /dev/null +++ b/apps/web/pages/_document.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import Document, { + Html, + Head, + Main, + NextScript, + DocumentContext, +} from "next/document"; +import { CssBaseline } from "@nextui-org/react"; + +class MyDocument extends Document { + static async getInitialProps(ctx: DocumentContext) { + const initialProps = await Document.getInitialProps(ctx); + return { + ...initialProps, + styles: React.Children.toArray([initialProps.styles]), + }; + } + + render() { + return ( + + + +
+ + + + ); + } +} + +export default MyDocument; diff --git a/apps/web/pages/index.tsx b/apps/web/pages/index.tsx index 6ec0887..2e5141b 100644 --- a/apps/web/pages/index.tsx +++ b/apps/web/pages/index.tsx @@ -1,10 +1,149 @@ -import { Button } from "ui"; +import { useMemo, useState } from "react"; +import { Dropdown, Input, Spacer, Textarea, styled } from "@nextui-org/react"; +import { BigNumber, utils as ethersUtils } from "ethers"; + +import AppBar from "../components/AppBar"; +import TokenInfo from "../components/TokenInfo"; +import Send from "../components/Send"; +import { Address, useAccount } from "wagmi"; +import TransferInfo from "../components/TransferInfo"; + +const Box = styled(`div`); + +const tokens = [ + { key: "ETH", name: "ETH (Native)" }, + { key: "ERC20", name: "ERC20" }, +]; export default function Web() { + const [selected, setSelected] = useState(new Set(["ETH"])); + const [address, setAddress] = useState
( + "0xda5bb55c0eA3f77321A888CA202cb84aE30C6AF5" + ); + const [transferDetails, setTransferDetails] = useState<{ + recipients: Address[]; + values: string[]; + }>({ + recipients: [], + values: [], + }); + const [totalValue, setTotalValue] = useState(BigNumber.from(0)); + const [error, setError] = useState(""); + + const selectedValue = useMemo( + () => Array.from(selected).join(", ").replaceAll("_", " "), + [selected] + ); + + const handleChange = (e: { target: { value: any } }) => { + const value = e.target.value; + + if (ethersUtils.isAddress(value)) { + setAddress(value); + setError(""); + } else setError("Invalid Address"); + }; + + const handleTextareaChange = (e: { target: { value: string } }) => { + const text: string = e.target.value; + + // Split each line + const lines = text.split(/\r?\n/); + + const splitLines = lines.map((t) => { + let r = t.split("="); + if (r.length !== 2) { + r = t.split(","); + if (r.length !== 2) { + r = t.split(" "); + } + } + + return r; + }); + + const recipients = splitLines.map((a) => a[0]); + const values = splitLines.map((a) => a[1]); + + const _totalValue = values.reduce( + (acc, v) => acc.add(BigNumber.from(v)), + BigNumber.from("0") + ); + + setTotalValue(_totalValue); + + setTransferDetails({ recipients: recipients as Address[], values }); + }; + return ( -
-

Web

-
+ <> + + + +

Disperse

+ + + + {selectedValue} + + + {(item) => ( + + {(item as any).name} + + )} + + + {selectedValue === "ERC20" ? ( + + ) : null} + + {address !== "0x" ? : null} + +